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">
<link rel="stylesheet" href="{% static 'css/styles.css' %}" type="text/css">
<title>Spirit Island Fear Tracker</title>
{% block header_script %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}

View File

@ -2,13 +2,13 @@
{% block content %}
<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">
<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 😱🎴;
<span class="fear_this_phase">{{ fear_this_phase }}</span>
<span class="fear_this_phase">{{ status.fear_this_phase }}</span>
😱 this phase
</div>
<div class="access-code">
@ -24,15 +24,15 @@
</div>
<form action="#" method="POST">
{% csrf_token %}
<input type="hidden" name="game_turn" value="{{ turn }}">
<input type="hidden" name="game_phase" value="{{ phase_id }}">
<input type="hidden" name="phase_name" value="{{ phase }} phase of turn #{{ turn }}">
<input type="hidden" name="game_turn" value="{{ status.turn }}">
<input type="hidden" name="game_phase" value="{{ status.phase_id }}">
<input type="hidden" name="phase_name" value="{{ status.phase }} phase of turn #{{ status.turn }}">
<div class="players">
{% for order, player in players.items %}
<div class="player player-{{ player.order }}">
<label for="player-{{ player.order }}-visible">
<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> 😱)
{{ player.name }} ({{ player.get_spirit_name }})
</div>
@ -56,9 +56,9 @@
id="player-{{ player.order }}-ready"
value="true"
{% 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>
<div class="player-effects">
<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 }}
@ -90,10 +90,300 @@
</div>
{% endfor %}
</div>
<input type="submit" name="update" value="Update fear">
{% if all_ready %}
<input type="submit" name="advance" value="Advance to next phase">
{% endif %}
<input type="submit" name="revert" value="Revert to previous phase">
<input type="submit" name="update" class="update-button" value="Update fear">
<input type="submit" name="advance" class="advance-button" value="Advance to next phase"{% if not status.all_ready %} disabled{% endif %}>
<input type="submit" name="revert" class="revert-button" value="Revert to previous phase">
</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 %}

View File

@ -24,7 +24,10 @@ urlpatterns = [
path('new/', views.new_game, name='new_game'),
url(r'^(?P<access_code>[a-zA-Z]{6})/', include([
path('', views.game, name='game'),
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

@ -1,11 +1,15 @@
from collections import OrderedDict
from http import HTTPStatus
from io import BytesIO
import hashlib
import json
import qrcode
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
from django.views.decorators.http import require_safe, require_http_methods,\
require_POST
from django.urls import reverse
from .forms import NewGameForm, JoinGameForm, PlayerFormSet
@ -89,16 +93,87 @@ def qr_code(request, access_code):
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
@lookup_access_code
@require_http_methods(["HEAD", "GET", "POST"])
def game(request, game):
players = OrderedDict()
for player in game.player_set.order_by('order').all():
player.total_fear = 0
return handle_game_request(request, game, update=False)
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.fear = OrderedDict()
players[player.order] = player
errors = []
fear_kind_dict = {
@ -106,14 +181,17 @@ def game(request, game):
'towns': 'towns_destroyed',
'cities': 'cities_destroyed',
}
current_value = None
current_phase = game.get_current_phase()
if request.method == 'POST':
post_data = request.POST
correct_phase =\
current_phase.game_turn == int(request.POST['game_turn'])\
and current_phase.game_phase == int(request.POST['game_phase'])
advance_phase = 'advance' in request.POST
revert_phase = 'revert' in request.POST
for key, value in request.POST.items():
current_phase.game_turn == int(post_data['game_turn'])\
and current_phase.game_phase == int(post_data['game_phase'])
advance_phase = 'advance' in post_data
revert_phase = 'revert' in post_data
for key, value in post_data.items():
sections = key.split('-')
if key in ['csrfmiddlewaretoken',
'update', 'advance', 'revert',
@ -121,7 +199,7 @@ def game(request, game):
or sections[-1] == 'orig':
pass
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 = players[player_ord]
if sections[2] == 'visible':
@ -151,7 +229,7 @@ def game(request, game):
f"from {orig} to {amount} " +
"was not processed.")
elif not correct_phase or advance_phase:
old_phase = request.POST['phase_name']
old_phase = post_data['phase_name']
errors.append(
f"{player.name} " +
f"({player.get_spirit_name()})'s " +
@ -198,14 +276,22 @@ def game(request, game):
for player in players.values():
player.ready = True
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
if update:
if errors:
res = {
'success': False,
'errors': '\n'.join(errors)
}
if current_value is not None:
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():
info = player.fear
if not info:
@ -217,17 +303,12 @@ def game(request, game):
'towns': 0,
'cities': 0,
}
return render(request, 'game.html', {
'access_code': game.access_code,
'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(),
'status': status_obj,
'status_json': status_string,
'players': players,
'all_ready': all(player.ready for player in players.values()),
'range': range(21),
'errors': errors,
})
@ -235,6 +316,10 @@ def game(request, game):
@lookup_access_code
@require_safe
def status(request, game):
# TODO status json
pass
def status(request, game, hashcode=None):
status_obj = game_status_object(game)
if hashcode == status_obj['hash']:
return HttpResponse(status=HTTPStatus.NOT_MODIFIED)
else:
status_string = json.dumps(status_obj)
return HttpResponse(status_string)