Compare commits

..

4 Commits

4 changed files with 426 additions and 47 deletions

View File

@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="stylesheet" href="{% static 'css/styles.css' %}" type="text/css"> <link rel="stylesheet" href="{% static 'css/styles.css' %}" type="text/css">
<title>Spirit Island Fear Tracker</title> <title>Spirit Island Fear Tracker</title>
{% block header_script %}{% endblock %}
</head> </head>
<body> <body>
{% block content %}{% endblock %} {% block content %}{% endblock %}

View File

@ -2,13 +2,13 @@
{% block content %} {% block content %}
<header> <header>
<div class="phase">{{ phase }} phase of turn #{{ turn }}</div> <div class="phase"><span class="status-phase">{{ status.phase }}</span> phase of turn #<span class="status-turn">{{ status.turn }}</span></div>
<div class="fear_summary"> <div class="fear_summary">
<span class="available_fear_cards">{{ available_fear_cards }}</span> <span class="available_fear_cards">{{ status.available_fear_cards }}</span>
😱🎴; 😱🎴;
<span class="fear_to_next_card">{{ fear_to_next_card }}</span> <span class="fear_to_next_card">{{ status.fear_to_next_card }}</span>
😱 to next 😱🎴; 😱 to next 😱🎴;
<span class="fear_this_phase">{{ fear_this_phase }}</span> <span class="fear_this_phase">{{ status.fear_this_phase }}</span>
😱 this phase 😱 this phase
</div> </div>
<div class="access-code"> <div class="access-code">
@ -24,15 +24,15 @@
</div> </div>
<form action="#" method="POST"> <form action="#" method="POST">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="game_turn" value="{{ turn }}"> <input type="hidden" name="game_turn" value="{{ status.turn }}">
<input type="hidden" name="game_phase" value="{{ phase_id }}"> <input type="hidden" name="game_phase" value="{{ status.phase_id }}">
<input type="hidden" name="phase_name" value="{{ phase }} phase of turn #{{ turn }}"> <input type="hidden" name="phase_name" value="{{ status.phase }} phase of turn #{{ status.turn }}">
<div class="players"> <div class="players">
{% for order, player in players.items %} {% for order, player in players.items %}
<div class="player player-{{ player.order }}"> <div class="player player-{{ player.order }}">
<label for="player-{{ player.order }}-visible"> <label for="player-{{ player.order }}-visible">
<div class="player-summary"> <div class="player-summary">
{% if player.ready %}🏁{% else %}⏳{% endif %} <span class="player-{{ player.order }}-ready">{% if player.ready %}🏁{% else %}⏳{% endif %}</span>
(<span class="player-total-fear player-{{ player.order }}-total-fear">{{ player.total_fear }}</span> 😱) (<span class="player-total-fear player-{{ player.order }}-total-fear">{{ player.total_fear }}</span> 😱)
{{ player.name }} ({{ player.get_spirit_name }}) {{ player.name }} ({{ player.get_spirit_name }})
</div> </div>
@ -56,9 +56,9 @@
id="player-{{ player.order }}-ready" id="player-{{ player.order }}-ready"
value="true" value="true"
{% if player.ready %} checked{% endif %}/>Done with {% if player.ready %} checked{% endif %}/>Done with
<span class="phase">{{ phase }} phase of turn #{{ turn }}</span> <span class="phase"><span class="status-phase">{{ status.phase }}</span> phase of turn #<span class="status-turn">{{ status.turn }}</span></span>
</label> </label>
<div class="player-effects"> <div class="player-effects" id="player-{{ player.order }}-effects">
{% for effect_num, effect in player.fear.items %} {% for effect_num, effect in player.fear.items %}
<div class="effect effect-{{ effect_num }}"> <div class="effect effect-{{ effect_num }}">
Effect #{{ effect_num|add:1 }} Effect #{{ effect_num|add:1 }}
@ -90,10 +90,300 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<input type="submit" name="update" value="Update fear"> <input type="submit" name="update" class="update-button" value="Update fear">
{% if all_ready %} <input type="submit" name="advance" class="advance-button" value="Advance to next phase"{% if not status.all_ready %} disabled{% endif %}>
<input type="submit" name="advance" value="Advance to next phase"> <input type="submit" name="revert" class="revert-button" value="Revert to previous phase">
{% endif %}
<input type="submit" name="revert" value="Revert to previous phase">
</form> </form>
{% block game_refresh %}
{% if not results_only %}
<div class="button-container">
<a href="{% url 'game' access_code=access_code %}" class="button" id="button-refresh">Refresh</a>
</div>
{% endif %}
{% endblock %}
{% endblock %}
{% block header_script %}
{% if not results_only %}
<template id="effect-template">
<div class="effect">
Effect #<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>
{% 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>
{% 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>
{% endfor %}
</select>
</div>
</template>
<script>
var statusObj = JSON.parse("{{ status_json|escapejs }}");
function ensureEffectRowExists(playerOrd, effectNum) {
if(document.querySelector(".player-" + playerOrd + " .effect-" + effectNum)) {
return;
}
if(effectNum > 0) {
ensureEffectRowExists(playerOrd, effectNum-1);
}
let effDiv = document.getElementById("player-" + playerOrd + "-effects");
let effectTemplate = document.querySelector("#effect-template");
let clone = effectTemplate.content.cloneNode(true);
clone.querySelector("div.effect").classList.add("effect-" + effectNum);
clone.querySelector(".effect-num").innerText = effectNum + 1;
let prefix = "player-" + playerOrd + "-effect-" + effectNum + "-";
["fear", "towns", "cities"].forEach(suffix => {
clone.querySelector("." + suffix).name = prefix + suffix;
clone.querySelector("." + suffix + "-orig").name = prefix + suffix + "-orig";
});
for(let el of clone.querySelectorAll('select')) {
el.addEventListener("change", formElementChanged);
}
effDiv.appendChild(clone);
}
function addEmptyEffects(clearAll) {
for(let effDiv of document.getElementsByClassName("player-effects")) {
if(clearAll) {
while(effDiv.lastElementChild) {
effDiv.removeChild(effDiv.lastElementChild);
}
}
var newEffectNum = null;
if(effDiv.childElementCount == 0) {
newEffectNum = 0;
} else {
let lastEffectDiv = effDiv.children[effDiv.children.length-1];
var lastEffectNum = null;
lastEffectDiv.classList.forEach(cls => {
if(cls.startsWith("effect-")) {
lastEffectNum = parseInt(cls.substring(7));
}
});
lastEffectDiv.querySelectorAll("select").forEach(child => {
if(child.value != "0") {
newEffectNum = lastEffectNum + 1;
}
});
}
if(newEffectNum != null) {
let playerOrd = effDiv.id.split("-")[1];
ensureEffectRowExists(playerOrd, newEffectNum);
}
}
}
function handleNewStatus(oldStatus, newStatus) {
{% block game_handle_new_status %}
if(oldStatus.hash == newStatus.hash) return true;
if(oldStatus.phase_id != newStatus.phase_id
|| oldStatus.turn != newStatus.turn) {
addEmptyEffects(true);
}
let form = document.forms[0];
for(let key in newStatus) {
if(key == "hash" || key == "phase_id" || key == "total_fear") {
// ignored
} else if(key == "all_ready") {
let all_ready = newStatus[key]
for(let el of document.getElementsByClassName("advance-button")) {
el.disabled = !all_ready;
}
} else if(key == "players") {
let players = newStatus[key];
for(let ord in players) {
let prefix = "player-" + ord + "-";
let player = players[ord];
for(let pkey in player) {
if(pkey == "ready") {
let ready = player[pkey];
for(let el of document.getElementsByClassName(prefix + "ready")) {
el.innerText = ready ? "🏁" : "⏳";
}
document.getElementById(prefix + "ready-orig").value = ready;
document.getElementById(prefix + "ready").checked = ready;
for(let el of document.querySelectorAll(".player-" + ord + " select")) {
el.disabled = ready;
}
} else if(pkey == "total_fear") {
let total_fear = player[pkey];
for(let el of document.getElementsByClassName(prefix + "total-fear")) {
el.innerText = total_fear;
}
} else if(pkey == "fear") {
let fear = player[pkey];
for(let effect_num in fear) {
ensureEffectRowExists(ord, effect_num);
let fprefix = prefix + "effect-" + effect_num + "-";
let effect = fear[effect_num];
for(let ekey in effect) {
let value = effect[ekey];
let eclass = ekey == "pure_fear" ? "fear" : ekey;
let eprefix = fprefix + eclass;
form.elements[eprefix].value = value;
form.elements[eprefix + "-orig"].value = value;
}
}
} else {
alert("Unknown player status key: " + pkey + "=" + player[pkey]);
return false;
}
}
}
addEmptyEffects(false);
} else {
var matching_elements = document.getElementsByClassName("status-"
+ key);
if(matching_elements.length == 0) {
matching_elements = document.getElementsByClassName(key);
}
if(matching_elements.length == 0) {
alert("Unknown status key: " + key + "=" + newStatus[key]);
return false;
}
let value = newStatus[key];
for(let el of matching_elements) {
el.innerText = value;
}
}
}
return true;
{% endblock %}
}
var activeRequests = new Set();
function formElementChanged(e) {
let form = document.forms[0];
let el = e.target;
let name = el.name;
let origName = name + "-orig";
let origEl = form.elements[origName];
let newValue = el.type == "checkbox" ? el.checked : el.value;
let oldValue = origEl.value;
el.disabled = true;
if(el.name.endsWith("ready") && newValue) {
let ord = name.split("-")[1];
// If marking a player ready, disable all selects immediately.
for(let el of document.querySelectorAll(".player-" + ord + " select")) {
el.disabled = true;
}
}
activeRequests.add(el);
statusObj.hash = '';
// From https://stackoverflow.com/a/5588435
function getCookie(name) {
var cookieValue = null;
if (document.cookie && document.cookie != '') {
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = cookies[i];
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) == (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
fetch("{% url 'update_game' access_code=access_code %}",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRFToken": getCookie("csrftoken"),
},
body: name + "=" + newValue + "&" + origName + "=" + oldValue
+ "&game_turn=" + statusObj["turn"]
+ "&game_phase=" + statusObj["phase_id"]
+ "&phase_name=" + statusObj["phase"]
+ "&csrfmiddlewaretoken=" + form.elements["csrfmiddlewaretoken"].value,
}).then(response => response.json()
).then(data => {
if("success" in data && data["success"]) {
form.elements[origName].value = newValue;
} else {
var updatedValue = oldValue;
if("value" in data) {
let updatedValue = data['value'];
origEl.value = updatedValue;
}
if(el.type == "checkbox") {
el.checked = updatedValue == "true";
} else {
el.value = updatedValue;
}
if("errors" in data) {
alert(data["errors"]);
} else {
alert("Unknown error: " + data);
}
}
el.disabled = false;
activeRequests.delete(el);
});
}
window.addEventListener("DOMContentLoaded", e => {
for(let updateButton of document.getElementsByClassName("update-button")) {
updateButton.disabled = true;
updateButton.style.display = "none";
}
let form = document.forms[0];
for(let el of form.elements) {
if(el.type == 'checkbox' && !el.name.endsWith("visible")) {
el.addEventListener("change", formElementChanged);
}
}
for(let el of document.querySelectorAll('select')) {
el.addEventListener("change", formElementChanged);
}
setInterval(function() {
if(activeRequests.size != 0) return;
fetch(new Request("{% url 'status' access_code=access_code %}"
+ (statusObj.hash != ""
? statusObj.hash + "/"
: "")))
.then(response => {
if(response.status === 304) {
// TODO Just skip the next step?
return statusObj;
} else {
return response.json();
}
})
.then(data => {
if(activeRequests.size != 0) return;
if(!handleNewStatus(statusObj, data)) {
document.getElementById("button-refresh").click();
} else {
statusObj = data;
}
});
}, 5000);
});
</script>
{% endif %}
{% endblock %} {% endblock %}

View File

@ -24,7 +24,10 @@ urlpatterns = [
path('new/', views.new_game, name='new_game'), path('new/', views.new_game, name='new_game'),
url(r'^(?P<access_code>[a-zA-Z]{6})/', include([ url(r'^(?P<access_code>[a-zA-Z]{6})/', include([
path('', views.game, name='game'), path('', views.game, name='game'),
path('update/', views.update_game, name='update_game'),
path('qr/', views.qr_code, name='qr_code'), path('qr/', views.qr_code, name='qr_code'),
path('status/', views.status, name='status'), path('status/', views.status, name='status'),
url('^status/(?P<hashcode>[a-z0-9]{64})/',
views.status, name='status'),
])), ])),
] ]

View File

@ -1,11 +1,15 @@
from collections import OrderedDict from collections import OrderedDict
from http import HTTPStatus
from io import BytesIO from io import BytesIO
import hashlib
import json
import qrcode import qrcode
from django.db import transaction from django.db import transaction
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_safe, require_http_methods from django.views.decorators.http import require_safe, require_http_methods,\
require_POST
from django.urls import reverse from django.urls import reverse
from .forms import NewGameForm, JoinGameForm, PlayerFormSet from .forms import NewGameForm, JoinGameForm, PlayerFormSet
@ -89,16 +93,87 @@ def qr_code(request, access_code):
return HttpResponse(output.getvalue(), content_type='image/png') return HttpResponse(output.getvalue(), content_type='image/png')
def load_players(game):
players = OrderedDict()
for player in game.player_set.order_by('order').all():
player.total_fear = 0
player.fear = OrderedDict()
players[player.order] = player
return players
def get_players_with_fear(game, current_phase=None, players=None):
if players is None:
players = load_players(game)
if current_phase is None:
current_phase = game.get_current_phase()
for fear in current_phase.fear_set.order_by('effect').all():
players[fear.player.order].fear[fear.effect] = {
'pure_fear': fear.pure_fear,
'towns': fear.towns_destroyed,
'cities': fear.cities_destroyed,
}
players[fear.player.order].total_fear +=\
fear.pure_fear + fear.towns_destroyed + 2*fear.cities_destroyed
return players
def game_status_object(game, current_phase=None, players_with_fear=None):
if current_phase is None:
current_phase = game.get_current_phase()
if players_with_fear is None:
players_with_fear = get_players_with_fear(game, current_phase)
players = OrderedDict()
for order, player in players_with_fear.items():
players[order] = {
'ready': player.ready,
'fear': player.fear,
'total_fear': player.total_fear
}
status_obj = {
'turn': game.game_turn,
'phase': game.get_current_phase_name(),
'phase_id': game.game_phase,
'available_fear_cards': game.num_available_fear_cards(),
'fear_to_next_card': game.get_fear_to_next_card(),
'fear_this_phase': current_phase.fear_this_phase(),
'total_fear': game.get_current_total_fear(),
'players': players,
'all_ready': all(player['ready'] for player in players.values()),
}
h = hashlib.sha256()
h.update(json.dumps(status_obj).encode('utf-8'))
status_hash = h.hexdigest()
status_obj['hash'] = status_hash
return status_obj
@transaction.atomic
@lookup_access_code
@require_POST
def update_game(request, game):
return handle_game_request(request, game, update=True)
@transaction.atomic @transaction.atomic
@lookup_access_code @lookup_access_code
@require_http_methods(["HEAD", "GET", "POST"]) @require_http_methods(["HEAD", "GET", "POST"])
def game(request, game): def game(request, game):
players = OrderedDict() return handle_game_request(request, game, update=False)
for player in game.player_set.order_by('order').all():
player.total_fear = 0
def handle_game_request(request, game, update):
# TODO Should check if game is over and redirect to summary table view.
players = load_players(game)
for player in players.values():
player.changed_to_ready = False player.changed_to_ready = False
player.fear = OrderedDict()
players[player.order] = player
errors = [] errors = []
fear_kind_dict = { fear_kind_dict = {
@ -106,14 +181,17 @@ def game(request, game):
'towns': 'towns_destroyed', 'towns': 'towns_destroyed',
'cities': 'cities_destroyed', 'cities': 'cities_destroyed',
} }
current_value = None
current_phase = game.get_current_phase() current_phase = game.get_current_phase()
if request.method == 'POST': if request.method == 'POST':
post_data = request.POST
correct_phase =\ correct_phase =\
current_phase.game_turn == int(request.POST['game_turn'])\ current_phase.game_turn == int(post_data['game_turn'])\
and current_phase.game_phase == int(request.POST['game_phase']) and current_phase.game_phase == int(post_data['game_phase'])
advance_phase = 'advance' in request.POST advance_phase = 'advance' in post_data
revert_phase = 'revert' in request.POST revert_phase = 'revert' in post_data
for key, value in request.POST.items(): for key, value in post_data.items():
sections = key.split('-') sections = key.split('-')
if key in ['csrfmiddlewaretoken', if key in ['csrfmiddlewaretoken',
'update', 'advance', 'revert', 'update', 'advance', 'revert',
@ -121,7 +199,7 @@ def game(request, game):
or sections[-1] == 'orig': or sections[-1] == 'orig':
pass pass
elif sections[0] == 'player': elif sections[0] == 'player':
orig_value = request.POST.get(key + '-orig', None) orig_value = post_data.get(key + '-orig', None)
player_ord = int(sections[1]) player_ord = int(sections[1])
player = players[player_ord] player = players[player_ord]
if sections[2] == 'visible': if sections[2] == 'visible':
@ -151,7 +229,7 @@ def game(request, game):
f"from {orig} to {amount} " + f"from {orig} to {amount} " +
"was not processed.") "was not processed.")
elif not correct_phase or advance_phase: elif not correct_phase or advance_phase:
old_phase = request.POST['phase_name'] old_phase = post_data['phase_name']
errors.append( errors.append(
f"{player.name} " + f"{player.name} " +
f"({player.get_spirit_name()})'s " + f"({player.get_spirit_name()})'s " +
@ -198,14 +276,22 @@ def game(request, game):
for player in players.values(): for player in players.values():
player.ready = True player.ready = True
for fear in current_phase.fear_set.order_by('effect').all(): if update:
players[fear.player.order].fear[fear.effect] = { if errors:
'pure_fear': fear.pure_fear, res = {
'towns': fear.towns_destroyed, 'success': False,
'cities': fear.cities_destroyed, 'errors': '\n'.join(errors)
} }
players[fear.player.order].total_fear +=\ if current_value is not None:
fear.pure_fear + fear.towns_destroyed + 2*fear.cities_destroyed res['value'] = current_value
else:
res = {'success': True}
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)
for player in players.values(): for player in players.values():
info = player.fear info = player.fear
if not info: if not info:
@ -217,17 +303,12 @@ def game(request, game):
'towns': 0, 'towns': 0,
'cities': 0, 'cities': 0,
} }
return render(request, 'game.html', { return render(request, 'game.html', {
'access_code': game.access_code, 'access_code': game.access_code,
'turn': game.game_turn, 'status': status_obj,
'phase': game.get_current_phase_name(), 'status_json': status_string,
'phase_id': game.game_phase,
'available_fear_cards': game.num_available_fear_cards(),
'fear_to_next_card': game.get_fear_to_next_card(),
'fear_this_phase': current_phase.fear_this_phase(),
'total_fear': game.get_current_total_fear(),
'players': players, 'players': players,
'all_ready': all(player.ready for player in players.values()),
'range': range(21), 'range': range(21),
'errors': errors, 'errors': errors,
}) })
@ -235,6 +316,10 @@ def game(request, game):
@lookup_access_code @lookup_access_code
@require_safe @require_safe
def status(request, game): def status(request, game, hashcode=None):
# TODO status json status_obj = game_status_object(game)
pass if hashcode == status_obj['hash']:
return HttpResponse(status=HTTPStatus.NOT_MODIFIED)
else:
status_string = json.dumps(status_obj)
return HttpResponse(status_string)