Compare commits

...

18 Commits

Author SHA1 Message Date
Daniel Perelman b30982bd7e Fixes to support Django Channels 4. 2023-07-29 15:31:26 -07:00
Daniel Perelman 0213931a8b Include expansion and complexity in spirit choice list. 2022-11-14 01:51:27 -08:00
Daniel Perelman a450f55af5 Avoid f string for constant string. 2022-11-14 00:59:41 -08:00
Daniel Perelman fe7bae770b Add spirit names up to Horizons. 2022-11-14 00:32:51 -08:00
Daniel Perelman 1c442bec41 Let nginx proxy_redirect handle subdirectory for redirects. 2020-04-11 23:32:28 -07:00
Daniel Perelman 96c41150f9 Push new status immediately if possible instead of invalidating and forcing a reload. 2020-04-11 23:32:28 -07:00
Daniel Perelman 9411b21d0f Wait to retry on error. 2020-04-11 23:32:28 -07:00
Daniel Perelman 44402c3907 Cancel long-polling for status from client side after 50 seconds. 2020-04-11 23:32:28 -07:00
Daniel Perelman 997d1260c8 Add script_prefix support. 2020-04-11 23:32:28 -07:00
Daniel Perelman 8aba4f8529 Disconnect when done with status consumer. 2020-04-11 23:32:28 -07:00
Daniel Perelman 564d79e831 Add some recovery attempts for race condition of missing status invalidation. 2020-04-11 23:32:28 -07:00
Daniel Perelman 93e521aca0 Initial support for long-polling. 2020-04-11 23:32:28 -07:00
Daniel Perelman 02a8ed8730 Add migrations to .gitignore. 2020-04-11 18:09:15 -04:00
Daniel Perelman f42edf2595 Add support for being run as asgi instead of wsgi. 2020-04-11 17:10:46 -04:00
Daniel Perelman 7ac94f33d2 Adjust styles to be more compact with larger text. 2020-03-30 18:15:58 -07:00
Daniel Perelman e9aa0b1a5c Adjust text to be more compact. 2020-03-30 18:15:39 -07:00
Daniel Perelman 34a7850975 Fix source code link. 2020-03-29 02:54:56 -07:00
Daniel Perelman 8ea70a1e1c Actually show options for enabling event phases. 2020-03-29 02:50:13 -07:00
12 changed files with 215 additions and 31 deletions

1
.gitignore vendored
View File

@ -60,3 +60,4 @@ target/
# sqlite database
*.sqlite3
fear_tracker/migrations

View File

@ -6,7 +6,7 @@ from .models import Game, Player
class PlayerForm(forms.Form):
name = forms.CharField(max_length=80, label="Name", required=False)
spirit = forms.TypedChoiceField(label="Spirit",
choices=enumerate(Player.SPIRIT_NAMES),
choices=Player.enumerate_spirit_names(),
empty_value=None, coerce=int)
@ -35,6 +35,12 @@ class NewGameForm(forms.Form):
combined_growth_spirit = forms.BooleanField(
required=False, initial=True,
label="Combine Growth and Spirit phases into a single phase")
enable_events = forms.BooleanField(
required=False, initial=False,
label="Enable event phase (Branch and Claw expansion)")
combined_event = forms.BooleanField(
required=False, initial=False,
label="Combine three event phase steps into one phase")
england_build = forms.BooleanField(
required=False, initial=False,
label="High Immigration (extra build phase for England level 3+)")

View File

@ -247,18 +247,42 @@ class Player(models.Model):
game = models.ForeignKey(Game, on_delete=models.CASCADE, db_index=True)
name = models.CharField(max_length=80)
SPIRIT_NAMES = [
"Lightning's Swift Strike",
"River Surges in Sunlight",
"Vital Strength of the Earth",
"Shadows Flicker Like Flame",
"Thunderspeaker",
"A Spread of Rampant Green",
"Ocean's Hungry Grasp",
"Bringer of Dreams and Nightmares",
"Keeper of the Forbidden Wilds",
"Sharp Fangs Behind the Leaves",
"Serpent Slumbering Beneath the Island",
"Heart of the Wildfire",
# From https://spiritislandwiki.com/index.php?title=List_of_Spirits
# Base game
("Lightning's Swift Strike", "SI", "L"),
("River Surges in Sunlight", "SI", "L"),
("Vital Strength of the Earth", "SI", "L"),
("Shadows Flicker Like Flame", "SI", "L"),
("Thunderspeaker", "SI", "M"),
("A Spread of Rampant Green", "SI", "M"),
("Ocean's Hungry Grasp", "SI", "H"),
("Bringer of Dreams and Nightmares", "SI", "H"),
# Branch and Claw
("Keeper of the Forbidden Wilds", "BC", "M"),
("Sharp Fangs Behind the Leaves", "BC", "M"),
# Promo Pack 1
("Serpent Slumbering Beneath the Island", "P1", "H"),
("Heart of the Wildfire", "P1", "H"),
# Jagged Earth
("Stone's Unyielding Defiance", "JE", "M"),
("Shifting Memory of Ages", "JE", "M"),
("Grinning Trickster Stirs Up Trouble", "JE", "M"),
("Lure of the Deep Wilderness", "JE", "M"),
("Many Minds Move as One", "JE", "M"),
("Volcano Looming High", "JE", "M"),
("Shroud of Silent Mist", "JE", "H"),
("Vengeance as a Burning Plague", "JE", "H"),
("Starlight Seeks Its Form", "JE", "V"),
("Fractured Days Split the Sky", "JE", "V"),
# Promo Pack 2
("Downpour Drenches the World", "P2", "H"),
("Finder of Paths Unseen", "P2", "V"),
# Horizons of Spirit Island
("Devouring Teeth Lurk Underfoot", "HS", "L"),
("Eyes Watch From the Trees", "HS", "L"),
("Fathomless Mud of the Swamp", "HS", "L"),
("Rising Heat of Stone and Sand", "HS", "L"),
("Sun-Bright Whirlwind", "HS", "L"),
]
spirit = models.IntegerField()
order = models.IntegerField()
@ -267,7 +291,12 @@ class Player(models.Model):
unique_together = (("game", "name"), ("game", "order"))
def get_spirit_name(self):
return Player.SPIRIT_NAMES[self.spirit]
return Player.SPIRIT_NAMES[self.spirit][0]
@staticmethod
def enumerate_spirit_names():
return [(i, f"[{spirit[1]},{spirit[2]}] {spirit[0]}") for (i, spirit)
in enumerate(Player.SPIRIT_NAMES)]
class Phase(models.Model):

21
fear_tracker/routing.py Normal file
View File

@ -0,0 +1,21 @@
from channels.routing import ProtocolTypeRouter, URLRouter
import django
from django.conf.urls import url
from django.core.asgi import get_asgi_application
# TODO Importing views breaks if django.setup() isn't called first...
# ... but this isn't the way this is supposed to work.
django.setup()
from . import views
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.as_asgi(), name='status'),
])),
url(r"", get_asgi_application()),
]),
})

View File

@ -1,3 +1,7 @@
body {
font-size: 1.75em;
}
footer hr {
border-width: 0;
border-top: 1px solid lightgray;
@ -148,8 +152,25 @@ footer a, footer a:visited {
.player-body {
display: none;
margin-left: 2ex;
}
.player-summary {
padding-left: 3ex;
text-indent: -3ex;
}
.player-visible:checked ~ .player-body {
display: block;
}
select {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
select, option {
font-size: inherit;
padding: 0;
margin: 0;
}

View File

@ -24,7 +24,7 @@
Spirit Island Fear Tracker was built by
<a href="https://aweirdimagination.net/~perelman/">Daniel Perelman</a>
|
<a href="https://git.aweirdimagination.net/perelman/fear-tracker">source
<a href="https://git.aweirdimagination.net/perelman/fear_tracker">source
code</a> licensed under
<a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPLv3+</a>
</p>

View File

@ -61,26 +61,26 @@
<div class="player-effects" id="player-{{ player.order }}-effects">
{% for effect_num, effect in player.fear.items %}
<div class="effect effect-{{ effect_num }}">
Effect #{{ effect_num|add:1 }}
#{{ effect_num|add:1 }}
<input type="hidden" name="player-{{ player.order }}-effect-{{ effect_num }}-fear-orig" value="{{ effect.pure_fear }}">
<select name="player-{{ player.order }}-effect-{{ effect_num }}-fear"{% if player.ready %} disabled{% endif %}>
{% for val in range %}
<option value="{{ val }}"{% if val == effect.pure_fear %} selected="selected"{% endif %}>{{ val }} 😱</option>
<option value="{{ val }}"{% if val == effect.pure_fear %} selected="selected"{% endif %}>{{ val }}😱</option>
{% endfor %}
</select>
<input type="hidden" name="player-{{ player.order }}-effect-{{ effect_num }}-towns-orig" value="{{ effect.towns }}">
<select name="player-{{ player.order }}-effect-{{ effect_num }}-towns"{% if player.ready %} disabled{% endif %}>
{% for val in range %}
<option value="{{ val }}"{% if val == effect.towns %} selected="selected"{% endif %}>{{ val }} 🏠</option>
<option value="{{ val }}"{% if val == effect.towns %} selected="selected"{% endif %}>{{ val }}🏠</option>
{% endfor %}
</select>
<input type="hidden" name="player-{{ player.order }}-effect-{{ effect_num }}-cities-orig" value="{{ effect.cities }}">
<select name="player-{{ player.order }}-effect-{{ effect_num }}-cities"{% if player.ready %} disabled{% endif %}>
{% for val in range %}
<option value="{{ val }}"{% if val == effect.cities %} selected="selected"{% endif %}>{{ val }} 🏙️</option>
<option value="{{ val }}"{% if val == effect.cities %} selected="selected"{% endif %}>{{ val }}🏙️</option>
{% endfor %}
</select>
</div>
@ -106,26 +106,26 @@
{% if not results_only %}
<template id="effect-template">
<div class="effect">
Effect #<span class="effect-num">?</span>
#<span class="effect-num">?</span>
<input type="hidden" class="fear-orig" value="0">
<select class="fear">
{% for val in range %}
<option value="{{ val }}"{% if val == 0 %} selected="selected"{% endif %}>{{ val }} 😱</option>
<option value="{{ val }}"{% if val == 0 %} selected="selected"{% endif %}>{{ val }}😱</option>
{% endfor %}
</select>
<input type="hidden" class="towns-orig" value="0">
<select class="towns">
{% for val in range %}
<option value="{{ val }}"{% if val == 0 %} selected="selected"{% endif %}>{{ val }} 🏠</option>
<option value="{{ val }}"{% if val == 0 %} selected="selected"{% endif %}>{{ val }}🏠</option>
{% endfor %}
</select>
<input type="hidden" class="cities-orig" value="0">
<select class="cities">
{% for val in range %}
<option value="{{ val }}"{% if val == 0 %} selected="selected"{% endif %}>{{ val }} 🏙️</option>
<option value="{{ val }}"{% if val == 0 %} selected="selected"{% endif %}>{{ val }}🏙️</option>
{% endfor %}
</select>
</div>
@ -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,6 +5,11 @@ 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.db import transaction
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
@ -54,6 +59,8 @@ def new_game(request):
game = Game()
game.combined_growth_spirit =\
form.cleaned_data.get('combined_growth_spirit')
game.enable_events = form.cleaned_data.get('enable_events')
game.combined_event = form.cleaned_data.get('combined_event')
game.england_build = form.cleaned_data.get('england_build')
game.fear_per_card =\
form.cleaned_data.get('fear_per_player') * num_players
@ -74,7 +81,7 @@ def new_game(request):
form = NewGameForm()
initial_player_data = [{'spirit': i}
for i in enumerate(Player.SPIRIT_NAMES)]
for (i, _) in Player.enumerate_spirit_names()]
formset = PlayerFormSet(initial=initial_player_data)
for player_form in formset:
player_form.player_id = int(player_form.prefix[5:]) + 1
@ -255,7 +262,7 @@ def handle_game_request(request, game, update):
f"#{effect_num} {effect_kind} " +
"was changed by another user to " +
f"{current_value}, so " +
f"your attempt to change it from " +
"your attempt to change it from " +
f"{orig} to {amount} " +
"was not processed.")
else:
@ -286,12 +293,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:
@ -322,4 +339,57 @@ 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 "status_string" in event and 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):
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)

11
fear_tracker_site/asgi.py Normal file
View File

@ -0,0 +1,11 @@
"""
ASGI entrypoint. Configures Django and then runs the application
defined in the ASGI_APPLICATION setting.
"""
import django
import fear_tracker.routing
django.setup()
application = fear_tracker.routing.application

View File

@ -0,0 +1,3 @@
import fear_tracker.routing
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',
@ -69,6 +70,7 @@ TEMPLATES = [
]
WSGI_APPLICATION = 'fear_tracker_site.wsgi.application'
ASGI_APPLICATION = 'fear_tracker_site.routing.application'
# Database
@ -81,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