Add very basic battle effect version

This commit is contained in:
Gabriel Augendre 2023-01-16 18:02:14 +01:00
parent 98184c72d5
commit 5c9718d14a
14 changed files with 320 additions and 16 deletions

View file

@ -18,6 +18,7 @@
<excludeFolder url="file://$MODULE_DIR$/src/public" /> <excludeFolder url="file://$MODULE_DIR$/src/public" />
<excludeFolder url="file://$MODULE_DIR$/src/test_reports" /> <excludeFolder url="file://$MODULE_DIR$/src/test_reports" />
<excludeFolder url="file://$MODULE_DIR$/src/character/tests/test_reports" /> <excludeFolder url="file://$MODULE_DIR$/src/character/tests/test_reports" />
<excludeFolder url="file://$MODULE_DIR$/src/common/static/vendor" />
</content> </content>
<orderEntry type="jdk" jdkName="Python 3.11 (charasheet)" jdkType="Python SDK" /> <orderEntry type="jdk" jdkName="Python 3.11 (charasheet)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />

View file

@ -4,7 +4,7 @@
[tool.pytest.ini_options] [tool.pytest.ini_options]
addopts = """ addopts = """
--html=test_reports/pytest_result/pytest.html --color=yes --durations 20 --html=test_reports/pytest_result/pytest.html --color=yes --durations 20
--no-cov-on-fail --strict-markers --no-cov-on-fail --strict-markers --reuse-db
--driver=Firefox --driver=Firefox
-W error -W error
-W "ignore:capabilities and desired_capabilities have been deprecated:DeprecationWarning:pytest_selenium.pytest_selenium" -W "ignore:capabilities and desired_capabilities have been deprecated:DeprecationWarning:pytest_selenium.pytest_selenium"
@ -68,3 +68,6 @@ flake8-bandit = [
"-S106", # Possible hardcoded password. "-S106", # Possible hardcoded password.
"-S311", # Standard pseudo-random generators are not suitable for security/cryptographic purposes. "-S311", # Standard pseudo-random generators are not suitable for security/cryptographic purposes.
] ]
flake8-bugbear = [
"-B011", # Do not call assert False since python -O removes these calls.
]

View file

@ -22,3 +22,18 @@ img.profile-pic {
max-height: 240px; max-height: 240px;
object-fit: cover; object-fit: cover;
} }
#effects-list {
display: flex;
flex-direction: row;
}
.effect {
width: 100px;
margin-right: 3em;
align-self: flex-end;
}
.effect .bar {
background-color: red;
}

View file

@ -1,5 +1,6 @@
import pytest import pytest
from django.core.management import call_command from django.core.management import call_command
from selenium.webdriver.remote.webdriver import WebDriver
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
@ -7,12 +8,25 @@ def collectstatic():
call_command("collectstatic", "--clear", "--noinput", "--verbosity=0") call_command("collectstatic", "--clear", "--noinput", "--verbosity=0")
@pytest.fixture
def live_server(settings, live_server):
settings.STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage"
return live_server
@pytest.fixture @pytest.fixture
def firefox_options(firefox_options): def firefox_options(firefox_options):
firefox_options.add_argument("-headless") firefox_options.add_argument("-headless")
return firefox_options return firefox_options
@pytest.fixture
def selenium(selenium: WebDriver) -> WebDriver:
selenium.implicitly_wait(3)
selenium.set_window_size(3860, 2140)
return selenium
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def settings(settings): def settings(settings):
settings.DEBUG_TOOLBAR = False settings.DEBUG_TOOLBAR = False

View file

@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError
from django.db.models import Q from django.db.models import Q
from character.models import Character from character.models import Character
from party.models import Party from party.models import BattleEffect, Party
class PartyForm(forms.ModelForm): class PartyForm(forms.ModelForm):
@ -38,3 +38,9 @@ class PartyForm(forms.ModelForm):
ValidationError(f"{character} is already a group member."), ValidationError(f"{character} is already a group member."),
) )
return invited return invited
class BattleEffectForm(forms.ModelForm):
class Meta:
model = BattleEffect
fields = ["name", "target", "description", "remaining_rounds"]

View file

@ -0,0 +1,79 @@
# Generated by Django 4.1.5 on 2023-01-16 16:21
import django.db.models.deletion
import django_extensions.db.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("party", "0002_party_invited_characters"),
]
operations = [
migrations.CreateModel(
name="BattleEffect",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
("name", models.CharField(max_length=100, verbose_name="nom")),
("target", models.CharField(max_length=100, verbose_name="cible")),
(
"description",
models.TextField(blank=True, verbose_name="description"),
),
(
"remaining_rounds",
models.SmallIntegerField(
default=-1,
help_text="-1 pour un effet permanent",
verbose_name="nombre de tours restants",
),
),
(
"created_by",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="effects",
to=settings.AUTH_USER_MODEL,
verbose_name="créé par",
),
),
(
"party",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="effects",
to="party.party",
verbose_name="groupe",
),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
]

View file

@ -1 +1 @@
0002_party_invited_characters 0003_battleeffect

View file

@ -14,6 +14,12 @@ class PartyQuerySet(models.QuerySet):
def played_by(self, user): def played_by(self, user):
return self.filter(characters__in=Character.objects.filter(player=user)) return self.filter(characters__in=Character.objects.filter(player=user))
def played_or_mastered_by(self, user):
return self.filter(
Q(game_master=user)
| Q(characters__in=Character.objects.filter(player=user))
).distinct()
def related_to(self, user): def related_to(self, user):
return self.filter( return self.filter(
Q(game_master=user) Q(game_master=user)
@ -61,3 +67,36 @@ class Party(UniquelyNamedModel, TimeStampedModel, models.Model):
def reset_stats(self): def reset_stats(self):
for character in self.characters.all(): for character in self.characters.all():
character.reset_stats() character.reset_stats()
class BattleEffectManager(models.Manager):
def decrease_all_remaining_rounds(self):
pass
class BattleEffect(TimeStampedModel, models.Model):
name = models.CharField(max_length=100, blank=False, null=False, verbose_name="nom")
target = models.CharField(
max_length=100, blank=False, null=False, verbose_name="cible"
)
description = models.TextField(blank=True, null=False, verbose_name="description")
remaining_rounds = models.SmallIntegerField(
blank=False,
default=-1,
verbose_name="nombre de tours restants",
help_text="-1 pour un effet permanent",
)
party = models.ForeignKey(
"party.Party",
on_delete=models.CASCADE,
related_name="effects",
verbose_name="groupe",
)
created_by = models.ForeignKey(
"common.User",
on_delete=models.CASCADE,
related_name="effects",
verbose_name="créé par",
)
objects = BattleEffectManager()

View file

@ -8,7 +8,8 @@
<h1>{{ party.name }}</h1> <h1>{{ party.name }}</h1>
<p>MJ : {{ party.game_master.get_full_name|default:party.game_master.username }}</p> <p>MJ : {{ party.game_master.get_full_name|default:party.game_master.username }}</p>
<p> <p>
<a href="{% url "party:reset_stats" pk=party.pk %}" id="reset-stats">Réinitialiser les stats</a> <a href="{% url "party:reset_stats" pk=party.pk %}" id="reset-stats">Réinitialiser
les stats</a>
</p> </p>
<h2>Personnages</h2> <h2>Personnages</h2>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-4"> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 row-cols-xl-4 g-4">
@ -28,4 +29,6 @@
</div> </div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<h2>Combat</h2>
{% include "party/snippets/effects.html" %}
{% endblock %} {% endblock %}

View file

@ -0,0 +1,8 @@
{% load django_bootstrap5 %}
<form>
{% csrf_token %}
{% bootstrap_form form %}
<button class="btn btn-primary" type="submit" hx-post="{% url "party:add_effect" pk=party.pk %}"
hx-target="#effects" hx-swap="outerHTML"
>Enregistrer</button>
</form>

View file

@ -0,0 +1,22 @@
<div id="effects">
<div id="effects-form">
<button
hx-get="{% url "party:add_effect" pk=party.pk %}"
hx-target="#effects-form"
hx-swap="innerHTML"
type="button"
id="add-effect"
class="btn btn-primary"><i class="fa-solid fa-plus"></i> Ajouter un effet
</button>
</div>
<div id="effects-list">
{% for effect in party.effects.all %}
<div class="effect">
<div style="height: calc({{ effect.remaining_rounds }}px * 20)" class="bar"></div>
<div class="name">{{ effect.name }}</div>
<div class="target">sur : {{ effect.target }}</div>
<div class="description text-secondary small">{{ effect.description }}</div>
</div>
{% endfor %}
</div>
</div>

View file

@ -1,8 +1,8 @@
import pytest import pytest
from django.core.management import call_command
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.live_server_helper import LiveServer from pytest_django.live_server_helper import LiveServer
from selenium.webdriver import Keys
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.webdriver import WebDriver from selenium.webdriver.firefox.webdriver import WebDriver
from selenium.webdriver.support.select import Select from selenium.webdriver.support.select import Select
@ -10,13 +10,13 @@ from selenium.webdriver.support.select import Select
from character.models import Character, Profile from character.models import Character, Profile
from character.tests.test_interactions import create_hurt_character, login from character.tests.test_interactions import create_hurt_character, login
from common.models import User from common.models import User
from party.models import Party from party.models import BattleEffect, Party
@pytest.mark.django_db @pytest.mark.django_db
def test_add_character_to_existing_group(selenium: WebDriver, live_server: LiveServer): def test_add_character_to_existing_group(
call_command("loaddata", "initial_data") selenium: WebDriver, live_server: LiveServer, initial_data: None
):
username, password = "gm", "password" username, password = "gm", "password"
gm = User.objects.create_user(username, password=password) gm = User.objects.create_user(username, password=password)
player = User.objects.create_user("player") player = User.objects.create_user("player")
@ -40,10 +40,8 @@ def test_add_character_to_existing_group(selenium: WebDriver, live_server: LiveS
@pytest.mark.django_db @pytest.mark.django_db
def test_gm_observe_invited_character_in_group( def test_gm_observe_invited_character_in_group(
selenium: WebDriver, live_server: LiveServer selenium: WebDriver, live_server: LiveServer, initial_data: None
): ):
call_command("loaddata", "initial_data")
username, password = "gm", "password" username, password = "gm", "password"
gm = User.objects.create_user(username, password=password) gm = User.objects.create_user(username, password=password)
player = User.objects.create_user("player") player = User.objects.create_user("player")
@ -66,10 +64,8 @@ def test_gm_observe_invited_character_in_group(
@pytest.mark.django_db @pytest.mark.django_db
def test_gm_observe_invited_character_in_two_groups( def test_gm_observe_invited_character_in_two_groups(
selenium: WebDriver, live_server: LiveServer selenium: WebDriver, live_server: LiveServer, initial_data: None
): ):
call_command("loaddata", "initial_data")
username, password = "gm", "password" username, password = "gm", "password"
gm = User.objects.create_user(username, password=password) gm = User.objects.create_user(username, password=password)
player = User.objects.create_user("player") player = User.objects.create_user("player")
@ -118,3 +114,102 @@ def test_reset_stats_view(
assert character.mana_remaining == character.mana_max assert character.mana_remaining == character.mana_max
assert character.recovery_points_remaining == character.recovery_points_max assert character.recovery_points_remaining == character.recovery_points_max
assert character.luck_points_remaining == character.luck_points_max assert character.luck_points_remaining == character.luck_points_max
@pytest.mark.django_db
def test_player_can_add_effect_to_group(
selenium: WebDriver, live_server: LiveServer, initial_data: None
):
"""Any member of a group can add effects to the group."""
user, password = "player", "password"
player = User.objects.create_user(user, password=password)
character = baker.make(Character, player=player)
party = baker.make(Party)
party.characters.add(character)
assert BattleEffect.objects.count() == 0
login(selenium, live_server, user, password)
url = reverse("party:details", kwargs={"pk": party.pk})
selenium.get(live_server.url + url)
selenium.find_element(By.ID, "add-effect").click()
selenium.find_element(By.ID, "id_name").send_keys("Agrandissement")
selenium.find_element(By.ID, "id_target").send_keys("Joueur 4")
selenium.find_element(By.ID, "id_description").send_keys(
"Le Magicien ou une cible volontaire (au contact) voit sa taille augmenter de "
"50% pendant [5 + Mod. d'INT] tours. Il gagne +2 aux DM au contact et aux "
"tests de FOR. Pataud, il subit un malus de -2 aux tests de DEX."
)
selenium.find_element(By.ID, "id_remaining_rounds").send_keys(Keys.DELETE, "8")
selenium.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
assert BattleEffect.objects.count() == 1
# Todo: assert effect is displayed
@pytest.mark.django_db
def test_gm_can_add_effect_to_group(
selenium: WebDriver, live_server: LiveServer, initial_data: None
):
"""The GM of a group can add effects to the group."""
user, password = "gm", "password"
gm = User.objects.create_user(user, password=password)
party = baker.make(Party, game_master=gm)
assert BattleEffect.objects.count() == 0
login(selenium, live_server, user, password)
url = reverse("party:details", kwargs={"pk": party.pk})
selenium.get(live_server.url + url)
selenium.find_element(By.ID, "add-effect").click()
selenium.find_element(By.ID, "id_name").send_keys("Brûlé")
selenium.find_element(By.ID, "id_target").send_keys("Boss 2")
selenium.find_element(By.ID, "id_description").send_keys(
"Le Magicien choisit une cible située à moins de 30 mètres. Si son attaque "
"magique réussit, la cible encaisse [1d6 + Mod. d'INT] DM et la flèche "
"enflamme ses vêtements. Chaque tour de combat suivant, le feu inflige 1d6 "
"dégâts supplémentaires. Sur un résultat de 1 à 2, les flammes s'éteignent et "
"le sort prend fin."
)
selenium.find_element(By.ID, "id_remaining_rounds").send_keys(Keys.DELETE, "-1")
selenium.find_element(By.CSS_SELECTOR, "button[type=submit]").click()
assert BattleEffect.objects.count() == 1
# Todo: assert effect is displayed
@pytest.mark.django_db
def test_gm_can_change_remaining_rounds(
selenium: WebDriver, live_server: LiveServer, initial_data: None
):
"""The GM of a group can increase or decrease the remaining rounds of effects."""
@pytest.mark.django_db
def test_gm_can_update_existing_effect(
selenium: WebDriver, live_server: LiveServer, initial_data: None
):
"""The GM of a group can update existing effect, except group and creator."""
@pytest.mark.django_db
def test_gm_can_delete_any_existing_effect(
selenium: WebDriver, live_server: LiveServer, initial_data: None
):
"""The GM of a group can delete any existing effect, running or terminated."""
@pytest.mark.django_db
def test_player_cant_change_existing_running_effect(
selenium: WebDriver, live_server: LiveServer, initial_data: None
):
"""Members of the group can only view existing running effects, no update."""
@pytest.mark.django_db
def test_player_can_delete_terminated_effect(
selenium: WebDriver, live_server: LiveServer, initial_data: None
):
"""Members of the group can delete terminated effects."""

View file

@ -10,6 +10,7 @@ urlpatterns = [
path("<int:pk>/change/", views.party_change, name="change"), path("<int:pk>/change/", views.party_change, name="change"),
path("<int:pk>/delete/", views.party_delete, name="delete"), path("<int:pk>/delete/", views.party_delete, name="delete"),
path("<int:pk>/reset_stats/", views.party_reset_stats, name="reset_stats"), path("<int:pk>/reset_stats/", views.party_reset_stats, name="reset_stats"),
path("<int:pk>/add_effect/", views.party_add_effect, name="add_effect"),
path("<int:pk>/leave/<int:character_pk>/", views.party_leave, name="leave"), path("<int:pk>/leave/<int:character_pk>/", views.party_leave, name="leave"),
path("<int:pk>/join/<int:character_pk>/", views.party_join, name="join"), path("<int:pk>/join/<int:character_pk>/", views.party_join, name="join"),
path("<int:pk>/refuse/<int:character_pk>/", views.party_refuse, name="refuse"), path("<int:pk>/refuse/<int:character_pk>/", views.party_refuse, name="refuse"),

View file

@ -3,7 +3,7 @@ from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from character.models import Character, HarmfulState from character.models import Character, HarmfulState
from party.forms import PartyForm from party.forms import BattleEffectForm, PartyForm
from party.models import Party from party.models import Party
@ -68,6 +68,24 @@ def party_reset_stats(request, pk):
return render(request, "party/party_reset_stats.html", context) return render(request, "party/party_reset_stats.html", context)
@login_required
def party_add_effect(request, pk):
party = get_object_or_404(Party.objects.played_or_mastered_by(request.user), pk=pk)
context = {"party": party}
if request.method == "GET":
form = BattleEffectForm()
else:
form = BattleEffectForm(request.POST or None)
if form.is_valid():
effect = form.save(commit=False)
effect.party = party
effect.created_by = request.user
effect.save()
return render(request, "party/snippets/effects.html", context)
context["form"] = form
return render(request, "party/snippets/add_effect_form.html", context)
@login_required @login_required
def party_change(request, pk): def party_change(request, pk):
party = get_object_or_404(Party.objects.managed_by(request.user), pk=pk) party = get_object_or_404(Party.objects.managed_by(request.user), pk=pk)