Compare commits
85 Commits
db1608fa38
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3800c5bdad | ||
|
|
12d575a84b | ||
|
|
c14b6d7062 | ||
|
|
a7c5468cbc | ||
|
|
4da8750c60 | ||
|
|
cf40f626e6 | ||
|
|
99a826f6c9 | ||
|
|
51fe2614fa | ||
|
|
56dc094b45 | ||
|
|
520fdf7862 | ||
|
|
e2cc38686f | ||
|
|
0bcc7567bb | ||
|
|
6654785f25 | ||
|
|
99a69202b9 | ||
|
|
55bb450d27 | ||
|
|
e28d55ad58 | ||
|
|
b110bb6d01 | ||
|
|
2892b51101 | ||
|
|
871e94b298 | ||
|
|
c3ab78cc57 | ||
|
|
c7370bda03 | ||
|
|
a15d91dfe6 | ||
|
|
fecb1fddca | ||
|
|
2028f1a544 | ||
|
|
40c747a837 | ||
|
|
40a55721ab | ||
|
|
d4518a0671 | ||
|
|
74f63a7721 | ||
|
|
bd3d7fc7bd | ||
|
|
c00288e256 | ||
|
|
b5de96660a | ||
|
|
96bb05a4ba | ||
|
|
4e07fcf38b | ||
|
|
b74f8e1bb1 | ||
|
|
188365f412 | ||
|
|
824f35590b | ||
|
|
43cb84e8f4 | ||
|
|
afe8e2b32c | ||
|
|
ca38875660 | ||
|
|
8538f76b13 | ||
|
|
2a7d4c7410 | ||
|
|
ed10e58383 | ||
|
|
b65cba5ed2 | ||
|
|
afe79f1a48 | ||
|
|
0e5e39b0dc | ||
|
|
4860b6ee2a | ||
|
|
c025a38709 | ||
|
|
581ea7e349 | ||
|
|
596175cd1c | ||
|
|
1aaf353066 | ||
|
|
441def9a34 | ||
|
|
736b59b5c0 | ||
|
|
a8592aeaec | ||
|
|
8b006be138 | ||
|
|
299a806862 | ||
|
|
fb782cf5ef | ||
|
|
224f5e2ad0 | ||
|
|
96379934d7 | ||
|
|
29a5658b01 | ||
|
|
73135df7a6 | ||
|
|
57f47cc77e | ||
|
|
5d21e79be5 | ||
|
|
ff0883002b | ||
|
|
7f927741d4 | ||
|
|
3bf48546e3 | ||
|
|
6817323f8e | ||
|
|
11283118d6 | ||
|
|
6c91ec0385 | ||
|
|
39db59c71a | ||
|
|
5f643350c5 | ||
|
|
ab41797e57 | ||
|
|
e35855f472 | ||
|
|
0e5805efd2 | ||
|
|
de99b538d2 | ||
|
|
c08b5b764e | ||
|
|
d63a4bec4a | ||
|
|
b35c9b483e | ||
|
|
30ea0fad9d | ||
|
|
62d5c738f9 | ||
|
|
f0f419ff7e | ||
|
|
0494710ce0 | ||
|
|
713e24863d | ||
|
|
b3bc422f46 | ||
|
|
c0016418cc | ||
|
|
4d52c4f54d |
@@ -23,6 +23,25 @@ steps:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
|
|
||||||
|
- name: test-two-browser-FTs
|
||||||
|
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||||
|
environment:
|
||||||
|
HEADLESS: 1
|
||||||
|
CELERY_BROKER_URL: redis://redis:6379/0
|
||||||
|
REDIS_URL: redis://redis:6379/1
|
||||||
|
STRIPE_SECRET_KEY:
|
||||||
|
from_secret: stripe_secret_key
|
||||||
|
STRIPE_PUBLISHABLE_KEY:
|
||||||
|
from_secret: stripe_publishable_key
|
||||||
|
commands:
|
||||||
|
- pip install -r requirements.txt
|
||||||
|
- cd ./src
|
||||||
|
- python manage.py collectstatic --noinput
|
||||||
|
- python manage.py test functional_tests --tag=two-browser
|
||||||
|
- python manage.py test functional_tests --tag=channels
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
|
||||||
- name: test-FTs
|
- name: test-FTs
|
||||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||||
environment:
|
environment:
|
||||||
@@ -37,8 +56,7 @@ steps:
|
|||||||
- pip install -r requirements.txt
|
- pip install -r requirements.txt
|
||||||
- cd ./src
|
- cd ./src
|
||||||
- python manage.py collectstatic --noinput
|
- python manage.py collectstatic --noinput
|
||||||
- python manage.py test functional_tests --parallel --exclude-tag=channels
|
- python manage.py test functional_tests --parallel --exclude-tag=channels --exclude-tag=two-browser
|
||||||
- python manage.py test functional_tests --tag=channels
|
|
||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ channels
|
|||||||
channels-redis
|
channels-redis
|
||||||
charset-normalizer==3.4.4
|
charset-normalizer==3.4.4
|
||||||
coverage
|
coverage
|
||||||
|
cryptography
|
||||||
cssselect==1.3.0
|
cssselect==1.3.0
|
||||||
daphne
|
daphne
|
||||||
dj-database-url
|
dj-database-url
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
celery
|
celery
|
||||||
|
cryptography
|
||||||
channels
|
channels
|
||||||
channels-redis
|
channels-redis
|
||||||
cssselect==1.3.0
|
cssselect==1.3.0
|
||||||
|
|||||||
7
src/apps/ap/apps.py
Normal file
7
src/apps/ap/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.ap"
|
||||||
|
label = "ap"
|
||||||
0
src/apps/ap/tests/__init__.py
Normal file
0
src/apps/ap/tests/__init__.py
Normal file
0
src/apps/ap/tests/integrated/__init__.py
Normal file
0
src/apps/ap/tests/integrated/__init__.py
Normal file
119
src/apps/ap/tests/integrated/test_ap_views.py
Normal file
119
src/apps/ap/tests/integrated/test_ap_views.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.drama.models import GameEvent, record
|
||||||
|
from apps.epic.models import Room
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class WebFingerTest(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="actor@test.io", username="actor")
|
||||||
|
|
||||||
|
def test_returns_jrd_for_known_user(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/.well-known/webfinger",
|
||||||
|
{"resource": "acct:actor@earthmanrpg.me"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response["Content-Type"], "application/jrd+json")
|
||||||
|
|
||||||
|
def test_jrd_links_to_actor_url(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/.well-known/webfinger",
|
||||||
|
{"resource": "acct:actor@earthmanrpg.me"},
|
||||||
|
)
|
||||||
|
data = json.loads(response.content)
|
||||||
|
hrefs = [link["href"] for link in data["links"]]
|
||||||
|
self.assertTrue(any("/ap/users/actor/" in href for href in hrefs))
|
||||||
|
|
||||||
|
def test_returns_404_for_unknown_user(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/.well-known/webfinger",
|
||||||
|
{"resource": "acct:nobody@earthmanrpg.me"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_returns_400_for_missing_resource(self):
|
||||||
|
response = self.client.get("/.well-known/webfinger")
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
|
||||||
|
class ActorViewTest(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="actor@test.io", username="actor")
|
||||||
|
|
||||||
|
def test_returns_200_for_known_user(self):
|
||||||
|
response = self.client.get("/ap/users/actor/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_returns_activity_json_content_type(self):
|
||||||
|
response = self.client.get("/ap/users/actor/")
|
||||||
|
self.assertEqual(response["Content-Type"], "application/activity+json")
|
||||||
|
|
||||||
|
def test_actor_has_required_fields(self):
|
||||||
|
response = self.client.get("/ap/users/actor/")
|
||||||
|
data = json.loads(response.content)
|
||||||
|
self.assertEqual(data["type"], "Person")
|
||||||
|
self.assertIn("id", data)
|
||||||
|
self.assertIn("outbox", data)
|
||||||
|
self.assertIn("publicKey", data)
|
||||||
|
|
||||||
|
def test_requires_no_authentication(self):
|
||||||
|
# AP Actor endpoints must be publicly accessible
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get("/ap/users/actor/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_returns_404_for_unknown_user(self):
|
||||||
|
response = self.client.get("/ap/users/nobody/")
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class OutboxViewTest(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="actor@test.io", username="actor")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
record(
|
||||||
|
self.room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Coin", renewal_days=7,
|
||||||
|
)
|
||||||
|
record(
|
||||||
|
self.room, GameEvent.ROLE_SELECTED, actor=self.user,
|
||||||
|
role="PC", slot_number=1, role_display="Player",
|
||||||
|
)
|
||||||
|
# INVITE_SENT is unsupported — should be excluded from outbox
|
||||||
|
record(self.room, GameEvent.INVITE_SENT, actor=self.user)
|
||||||
|
|
||||||
|
def test_returns_200(self):
|
||||||
|
response = self.client.get("/ap/users/actor/outbox/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_returns_activity_json_content_type(self):
|
||||||
|
response = self.client.get("/ap/users/actor/outbox/")
|
||||||
|
self.assertEqual(response["Content-Type"], "application/activity+json")
|
||||||
|
|
||||||
|
def test_outbox_is_ordered_collection(self):
|
||||||
|
response = self.client.get("/ap/users/actor/outbox/")
|
||||||
|
data = json.loads(response.content)
|
||||||
|
self.assertEqual(data["type"], "OrderedCollection")
|
||||||
|
|
||||||
|
def test_total_items_excludes_unsupported_verbs(self):
|
||||||
|
response = self.client.get("/ap/users/actor/outbox/")
|
||||||
|
data = json.loads(response.content)
|
||||||
|
# 2 supported events (SLOT_FILLED + ROLE_SELECTED); INVITE_SENT excluded
|
||||||
|
self.assertEqual(data["totalItems"], 2)
|
||||||
|
|
||||||
|
def test_requires_no_authentication(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get("/ap/users/actor/outbox/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_returns_404_for_unknown_user(self):
|
||||||
|
response = self.client.get("/ap/users/nobody/outbox/")
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
0
src/apps/ap/tests/unit/__init__.py
Normal file
0
src/apps/ap/tests/unit/__init__.py
Normal file
88
src/apps/ap/tests/unit/test_activity.py
Normal file
88
src/apps/ap/tests/unit/test_activity.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.drama.models import GameEvent, record
|
||||||
|
from apps.epic.models import Room
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
BASE = "https://earthmanrpg.me"
|
||||||
|
|
||||||
|
|
||||||
|
class ToActivityTest(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="actor@test.io", username="testactor")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
|
||||||
|
def _record(self, verb, **data):
|
||||||
|
return record(self.room, verb, actor=self.user, **data)
|
||||||
|
|
||||||
|
def test_slot_filled_returns_join_gate_activity(self):
|
||||||
|
event = self._record(
|
||||||
|
GameEvent.SLOT_FILLED,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Coin", renewal_days=7,
|
||||||
|
)
|
||||||
|
activity = event.to_activity(BASE)
|
||||||
|
self.assertIsNotNone(activity)
|
||||||
|
self.assertEqual(activity["type"], "earthman:JoinGate")
|
||||||
|
|
||||||
|
def test_role_selected_returns_select_role_activity(self):
|
||||||
|
event = self._record(
|
||||||
|
GameEvent.ROLE_SELECTED,
|
||||||
|
role="PC", slot_number=1, role_display="Player",
|
||||||
|
)
|
||||||
|
activity = event.to_activity(BASE)
|
||||||
|
self.assertIsNotNone(activity)
|
||||||
|
self.assertEqual(activity["type"], "earthman:SelectRole")
|
||||||
|
|
||||||
|
def test_room_created_returns_create_activity(self):
|
||||||
|
event = self._record(GameEvent.ROOM_CREATED)
|
||||||
|
activity = event.to_activity(BASE)
|
||||||
|
self.assertIsNotNone(activity)
|
||||||
|
self.assertEqual(activity["type"], "Create")
|
||||||
|
|
||||||
|
def test_unsupported_verb_returns_none(self):
|
||||||
|
event = self._record(GameEvent.INVITE_SENT)
|
||||||
|
self.assertIsNone(event.to_activity(BASE))
|
||||||
|
|
||||||
|
def test_activity_contains_actor_url(self):
|
||||||
|
event = self._record(
|
||||||
|
GameEvent.ROLE_SELECTED,
|
||||||
|
role="PC", slot_number=1, role_display="Player",
|
||||||
|
)
|
||||||
|
activity = event.to_activity(BASE)
|
||||||
|
self.assertIn(BASE, activity["actor"])
|
||||||
|
|
||||||
|
def test_activity_contains_object_url(self):
|
||||||
|
event = self._record(
|
||||||
|
GameEvent.SLOT_FILLED,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Coin", renewal_days=7,
|
||||||
|
)
|
||||||
|
activity = event.to_activity(BASE)
|
||||||
|
self.assertIn(str(self.room.id), activity["object"])
|
||||||
|
|
||||||
|
|
||||||
|
class EnsureKeypairTest(TestCase):
|
||||||
|
|
||||||
|
def test_ensure_keypair_populates_both_fields(self):
|
||||||
|
user = User.objects.create(email="keys@test.io")
|
||||||
|
self.assertEqual(user.ap_public_key, "")
|
||||||
|
self.assertEqual(user.ap_private_key, "")
|
||||||
|
user.ensure_keypair()
|
||||||
|
self.assertTrue(user.ap_public_key.startswith("-----BEGIN PUBLIC KEY-----"))
|
||||||
|
self.assertTrue(user.ap_private_key.startswith("-----BEGIN PRIVATE KEY-----"))
|
||||||
|
|
||||||
|
def test_ensure_keypair_persists_to_db(self):
|
||||||
|
user = User.objects.create(email="persist@test.io")
|
||||||
|
user.ensure_keypair()
|
||||||
|
refreshed = User.objects.get(pk=user.pk)
|
||||||
|
self.assertTrue(refreshed.ap_public_key.startswith("-----BEGIN PUBLIC KEY-----"))
|
||||||
|
|
||||||
|
def test_ensure_keypair_is_idempotent(self):
|
||||||
|
user = User.objects.create(email="idem@test.io")
|
||||||
|
user.ensure_keypair()
|
||||||
|
original_pub = user.ap_public_key
|
||||||
|
user.ensure_keypair()
|
||||||
|
self.assertEqual(user.ap_public_key, original_pub)
|
||||||
10
src/apps/ap/urls.py
Normal file
10
src/apps/ap/urls.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "ap"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("users/<str:username>/", views.actor, name="actor"),
|
||||||
|
path("users/<str:username>/outbox/", views.outbox, name="outbox"),
|
||||||
|
]
|
||||||
83
src/apps/ap/views.py
Normal file
83
src/apps/ap/views.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from django.http import HttpResponse, JsonResponse
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
AP_CONTEXT = [
|
||||||
|
"https://www.w3.org/ns/activitystreams",
|
||||||
|
{"earthman": "https://earthmanrpg.me/ns#"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url(request):
|
||||||
|
return f"{request.scheme}://{request.get_host()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ap_response(data):
|
||||||
|
return HttpResponse(
|
||||||
|
json.dumps(data),
|
||||||
|
content_type="application/activity+json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def webfinger(request):
|
||||||
|
resource = request.GET.get("resource", "")
|
||||||
|
if not resource:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
# Expect acct:username@host
|
||||||
|
if not resource.startswith("acct:"):
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
username = resource[len("acct:"):].split("@")[0]
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
base = _base_url(request)
|
||||||
|
data = {
|
||||||
|
"subject": resource,
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"rel": "self",
|
||||||
|
"type": "application/activity+json",
|
||||||
|
"href": f"{base}/ap/users/{user.username}/",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return HttpResponse(json.dumps(data), content_type="application/jrd+json")
|
||||||
|
|
||||||
|
|
||||||
|
def actor(request, username):
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
user.ensure_keypair()
|
||||||
|
base = _base_url(request)
|
||||||
|
actor_url = f"{base}/ap/users/{username}/"
|
||||||
|
data = {
|
||||||
|
"@context": AP_CONTEXT,
|
||||||
|
"id": actor_url,
|
||||||
|
"type": "Person",
|
||||||
|
"preferredUsername": username,
|
||||||
|
"inbox": f"{actor_url}inbox/",
|
||||||
|
"outbox": f"{actor_url}outbox/",
|
||||||
|
"publicKey": {
|
||||||
|
"id": f"{actor_url}#main-key",
|
||||||
|
"owner": actor_url,
|
||||||
|
"publicKeyPem": user.ap_public_key,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return _ap_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
def outbox(request, username):
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
base = _base_url(request)
|
||||||
|
events = user.game_events.select_related("room").order_by("timestamp")
|
||||||
|
activities = [a for e in events if (a := e.to_activity(base)) is not None]
|
||||||
|
actor_url = f"{base}/ap/users/{username}/"
|
||||||
|
data = {
|
||||||
|
"@context": AP_CONTEXT,
|
||||||
|
"id": f"{actor_url}outbox/",
|
||||||
|
"type": "OrderedCollection",
|
||||||
|
"totalItems": len(activities),
|
||||||
|
"orderedItems": activities,
|
||||||
|
}
|
||||||
|
return _ap_response(data)
|
||||||
25
src/apps/applets/migrations/0008_game_kit_applets.py
Normal file
25
src/apps/applets/migrations/0008_game_kit_applets.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def seed_game_kit_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
for slug, name in [
|
||||||
|
('gk-trinkets', 'Trinkets'),
|
||||||
|
('gk-tokens', 'Tokens'),
|
||||||
|
('gk-decks', 'Card Decks'),
|
||||||
|
('gk-dice', 'Dice Sets'),
|
||||||
|
]:
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug=slug,
|
||||||
|
defaults={'name': name, 'grid_cols': 3, 'grid_rows': 3, 'context': 'game-kit'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0007_fix_billboard_applets'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_game_kit_applets, migrations.RunPython.noop)
|
||||||
|
]
|
||||||
@@ -28,6 +28,7 @@ document.addEventListener('DOMContentLoaded', initGearMenus);
|
|||||||
const appletContainerIds = new Set([
|
const appletContainerIds = new Set([
|
||||||
'id_applets_container',
|
'id_applets_container',
|
||||||
'id_game_applets_container',
|
'id_game_applets_container',
|
||||||
|
'id_gk_sections_container',
|
||||||
'id_wallet_applets_container',
|
'id_wallet_applets_container',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,11 @@ class BillscrollViewTest(TestCase):
|
|||||||
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
self.assertEqual(response.context["scroll_position"], 250)
|
self.assertEqual(response.context["scroll_position"], 250)
|
||||||
|
|
||||||
|
def test_scroll_renders_event_body_and_time_columns(self):
|
||||||
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
|
self.assertContains(response, 'class="drama-event-body"')
|
||||||
|
self.assertContains(response, 'class="drama-event-time"')
|
||||||
|
|
||||||
|
|
||||||
class SaveScrollPositionTest(TestCase):
|
class SaveScrollPositionTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -53,7 +53,7 @@ class GameEvent(models.Model):
|
|||||||
token = d.get("token_display") or _token_names.get(code, code)
|
token = d.get("token_display") or _token_names.get(code, code)
|
||||||
days = d.get("renewal_days", 7)
|
days = d.get("renewal_days", 7)
|
||||||
slot = d.get("slot_number", "?")
|
slot = d.get("slot_number", "?")
|
||||||
return f"deposits a {token} for slot {slot} ({days} days)"
|
return f"deposits a {token} for slot {slot} (expires in {days} days)."
|
||||||
if self.verb == self.SLOT_RESERVED:
|
if self.verb == self.SLOT_RESERVED:
|
||||||
return "reserves a seat"
|
return "reserves a seat"
|
||||||
if self.verb == self.SLOT_RETURNED:
|
if self.verb == self.SLOT_RETURNED:
|
||||||
@@ -73,11 +73,40 @@ class GameEvent(models.Model):
|
|||||||
}
|
}
|
||||||
code = d.get("role", "?")
|
code = d.get("role", "?")
|
||||||
role = d.get("role_display") or _role_names.get(code, code)
|
role = d.get("role_display") or _role_names.get(code, code)
|
||||||
return f"elects to start as {role}"
|
return f"elects to start as the {role}, and will enjoy affinity with this Role for the remainder of the game."
|
||||||
if self.verb == self.ROLES_REVEALED:
|
if self.verb == self.ROLES_REVEALED:
|
||||||
return "All roles assigned"
|
return "All roles assigned"
|
||||||
return self.verb
|
return self.verb
|
||||||
|
|
||||||
|
def to_activity(self, base_url):
|
||||||
|
"""Serialise this event as an AS2 Activity dict, or None if unsupported."""
|
||||||
|
if not self.actor or not self.actor.username:
|
||||||
|
return None
|
||||||
|
actor_url = f"{base_url}/ap/users/{self.actor.username}/"
|
||||||
|
room_url = f"{base_url}/gameboard/room/{self.room_id}/"
|
||||||
|
if self.verb == self.SLOT_FILLED:
|
||||||
|
return {
|
||||||
|
"type": "earthman:JoinGate",
|
||||||
|
"actor": actor_url,
|
||||||
|
"object": room_url,
|
||||||
|
"summary": self.to_prose(),
|
||||||
|
}
|
||||||
|
if self.verb == self.ROLE_SELECTED:
|
||||||
|
return {
|
||||||
|
"type": "earthman:SelectRole",
|
||||||
|
"actor": actor_url,
|
||||||
|
"object": room_url,
|
||||||
|
"summary": self.to_prose(),
|
||||||
|
}
|
||||||
|
if self.verb == self.ROOM_CREATED:
|
||||||
|
return {
|
||||||
|
"type": "Create",
|
||||||
|
"actor": actor_url,
|
||||||
|
"object": room_url,
|
||||||
|
"summary": self.to_prose(),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
actor = self.actor.email if self.actor else "system"
|
actor = self.actor.email if self.actor else "system"
|
||||||
return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor} → {self.verb}"
|
return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor} → {self.verb}"
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
from django.urls import reverse
|
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from apps.drama.models import GameEvent
|
|
||||||
from apps.epic.models import GateSlot, Room, TableSeat
|
|
||||||
from apps.lyric.models import Token, User
|
|
||||||
|
|
||||||
|
|
||||||
class ConfirmTokenRecordsSlotFilledTest(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = User.objects.create(email="gamer@test.io")
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
|
||||||
self.token = Token.objects.create(user=self.user, token_type=Token.TITHE)
|
|
||||||
self.slot = self.room.gate_slots.get(slot_number=1)
|
|
||||||
self.slot.gamer = self.user
|
|
||||||
self.slot.status = GateSlot.RESERVED
|
|
||||||
self.slot.reserved_at = timezone.now()
|
|
||||||
self.slot.save()
|
|
||||||
|
|
||||||
def test_confirm_token_records_slot_filled_event(self):
|
|
||||||
session = self.client.session
|
|
||||||
session["kit_token_id"] = str(self.token.id)
|
|
||||||
session.save()
|
|
||||||
self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
|
|
||||||
event = GameEvent.objects.get(room=self.room, verb=GameEvent.SLOT_FILLED)
|
|
||||||
self.assertEqual(event.actor, self.user)
|
|
||||||
self.assertEqual(event.data["slot_number"], 1)
|
|
||||||
self.assertEqual(event.data["token_type"], Token.TITHE)
|
|
||||||
|
|
||||||
def test_no_event_recorded_if_no_reserved_slot(self):
|
|
||||||
self.slot.gamer = None
|
|
||||||
self.slot.status = GateSlot.EMPTY
|
|
||||||
self.slot.save()
|
|
||||||
self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
|
|
||||||
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.SLOT_FILLED).count(), 0)
|
|
||||||
|
|
||||||
|
|
||||||
class SelectRoleRecordsRoleSelectedTest(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = User.objects.create(email="player@test.io")
|
|
||||||
self.client.force_login(self.user)
|
|
||||||
self.room = Room.objects.create(
|
|
||||||
name="Role Room", owner=self.user, table_status=Room.ROLE_SELECT
|
|
||||||
)
|
|
||||||
self.seat = TableSeat.objects.create(
|
|
||||||
room=self.room, gamer=self.user, slot_number=1
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_select_role_records_role_selected_event(self):
|
|
||||||
self.client.post(
|
|
||||||
reverse("epic:select_role", args=[self.room.id]),
|
|
||||||
data={"role": "PC"},
|
|
||||||
)
|
|
||||||
event = GameEvent.objects.get(room=self.room, verb=GameEvent.ROLE_SELECTED)
|
|
||||||
self.assertEqual(event.actor, self.user)
|
|
||||||
self.assertEqual(event.data["role"], "PC")
|
|
||||||
self.assertEqual(event.data["slot_number"], 1)
|
|
||||||
|
|
||||||
def test_roles_revealed_event_recorded_when_all_seats_assigned(self):
|
|
||||||
# Only one seat — assigning it triggers roles_revealed
|
|
||||||
self.client.post(
|
|
||||||
reverse("epic:select_role", args=[self.room.id]),
|
|
||||||
data={"role": "PC"},
|
|
||||||
)
|
|
||||||
self.assertTrue(
|
|
||||||
GameEvent.objects.filter(room=self.room, verb=GameEvent.ROLES_REVEALED).exists()
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_no_event_if_role_already_taken(self):
|
|
||||||
TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=2, role="PC")
|
|
||||||
self.client.post(
|
|
||||||
reverse("epic:select_role", args=[self.room.id]),
|
|
||||||
data={"role": "PC"},
|
|
||||||
)
|
|
||||||
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.ROLE_SELECTED).count(), 0)
|
|
||||||
@@ -1,18 +1,58 @@
|
|||||||
|
from channels.db import database_sync_to_async
|
||||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||||
|
|
||||||
|
|
||||||
|
LEVITY_ROLES = {"PC", "NC", "SC"}
|
||||||
|
GRAVITY_ROLES = {"BC", "EC", "AC"}
|
||||||
|
|
||||||
|
|
||||||
class RoomConsumer(AsyncJsonWebsocketConsumer):
|
class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||||
async def connect(self):
|
async def connect(self):
|
||||||
self.room_id = self.scope["url_route"]["kwargs"]["room_id"]
|
self.room_id = self.scope["url_route"]["kwargs"]["room_id"]
|
||||||
self.group_name = f"room_{self.room_id}"
|
self.group_name = f"room_{self.room_id}"
|
||||||
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
||||||
|
|
||||||
|
self.cursor_group = None
|
||||||
|
user = self.scope.get("user")
|
||||||
|
if user and user.is_authenticated:
|
||||||
|
seat = await self._get_seat(user)
|
||||||
|
if seat:
|
||||||
|
if seat.role in LEVITY_ROLES:
|
||||||
|
self.cursor_group = f"cursors_{self.room_id}_levity"
|
||||||
|
elif seat.role in GRAVITY_ROLES:
|
||||||
|
self.cursor_group = f"cursors_{self.room_id}_gravity"
|
||||||
|
if self.cursor_group:
|
||||||
|
await self.channel_layer.group_add(self.cursor_group, self.channel_name)
|
||||||
|
|
||||||
await self.accept()
|
await self.accept()
|
||||||
|
|
||||||
async def disconnect(self, close_code):
|
async def disconnect(self, close_code):
|
||||||
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
||||||
|
if self.cursor_group:
|
||||||
|
await self.channel_layer.group_discard(self.cursor_group, self.channel_name)
|
||||||
|
|
||||||
async def receive_json(self, content):
|
async def receive_json(self, content):
|
||||||
pass # handlers added as events introduced
|
msg_type = content.get("type")
|
||||||
|
if msg_type == "cursor_move" and self.cursor_group:
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
self.cursor_group,
|
||||||
|
{"type": "cursor_move", "x": content.get("x"), "y": content.get("y")},
|
||||||
|
)
|
||||||
|
elif msg_type == "sig_hover" and self.cursor_group:
|
||||||
|
await self.channel_layer.group_send(
|
||||||
|
self.cursor_group,
|
||||||
|
{
|
||||||
|
"type": "sig_hover",
|
||||||
|
"card_id": content.get("card_id"),
|
||||||
|
"role": content.get("role"),
|
||||||
|
"active": content.get("active"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@database_sync_to_async
|
||||||
|
def _get_seat(self, user):
|
||||||
|
from apps.epic.models import TableSeat
|
||||||
|
return TableSeat.objects.filter(room_id=self.room_id, gamer=user).first()
|
||||||
|
|
||||||
async def gate_update(self, event):
|
async def gate_update(self, event):
|
||||||
await self.send_json(event)
|
await self.send_json(event)
|
||||||
@@ -23,5 +63,20 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
|
|||||||
async def turn_changed(self, event):
|
async def turn_changed(self, event):
|
||||||
await self.send_json(event)
|
await self.send_json(event)
|
||||||
|
|
||||||
async def roles_revealed(self, event):
|
async def all_roles_filled(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def sig_select_started(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def sig_selected(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def sig_hover(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def sig_reserved(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def cursor_move(self, event):
|
||||||
await self.send_json(event)
|
await self.send_json(event)
|
||||||
|
|||||||
63
src/apps/epic/migrations/0016_reorder_earthman_popes.py
Normal file
63
src/apps/epic/migrations/0016_reorder_earthman_popes.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Data migration: reorder the five Pope cards.
|
||||||
|
|
||||||
|
New assignment (card number → title):
|
||||||
|
1 → Chancellor 2 → President 3 → Tsar 4 → Chairman 5 → Emperor
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
POPE_RENAMES = {
|
||||||
|
1: ("Pope 1: Chancellor", "pope-1-chancellor"),
|
||||||
|
2: ("Pope 2: President", "pope-2-president"),
|
||||||
|
3: ("Pope 3: Tsar", "pope-3-tsar"),
|
||||||
|
4: ("Pope 4: Chairman", "pope-4-chairman"),
|
||||||
|
5: ("Pope 5: Emperor", "pope-5-emperor"),
|
||||||
|
}
|
||||||
|
|
||||||
|
POPE_ORIGINALS = {
|
||||||
|
1: ("Pope 1: President", "pope-1-president"),
|
||||||
|
2: ("Pope 2: Tsar", "pope-2-tsar"),
|
||||||
|
3: ("Pope 3: Chairman", "pope-3-chairman"),
|
||||||
|
4: ("Pope 4: Emperor", "pope-4-emperor"),
|
||||||
|
5: ("Pope 5: Chancellor", "pope-5-chancellor"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def rename_forward(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
|
||||||
|
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||||
|
if not earthman:
|
||||||
|
return
|
||||||
|
|
||||||
|
for number, (new_name, new_slug) in POPE_RENAMES.items():
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=number
|
||||||
|
).update(name=new_name, slug=new_slug)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_reverse(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
|
||||||
|
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||||
|
if not earthman:
|
||||||
|
return
|
||||||
|
|
||||||
|
for number, (old_name, old_slug) in POPE_ORIGINALS.items():
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=number
|
||||||
|
).update(name=old_name, slug=old_slug)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0015_rename_classical_element_earth_to_stone"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
|
||||||
|
]
|
||||||
19
src/apps/epic/migrations/0017_tableseat_significator_fk.py
Normal file
19
src/apps/epic/migrations/0017_tableseat_significator_fk.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-25 05:46
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0016_reorder_earthman_popes'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='tableseat',
|
||||||
|
name='significator',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='significator_seats', to='epic.tarotcard'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
src/apps/epic/migrations/0018_alter_tarotcard_suit.py
Normal file
18
src/apps/epic/migrations/0018_alter_tarotcard_suit.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-01 17:37
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0017_tableseat_significator_fk'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tarotcard',
|
||||||
|
name='suit',
|
||||||
|
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles')], max_length=10, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""
|
||||||
|
Data migration: rename The Schiz (card 0) and the five Pope cards (cards 1–5)
|
||||||
|
in the Earthman deck.
|
||||||
|
|
||||||
|
0: "The Schiz" → "The Nomad"
|
||||||
|
1: "Pope 1: Chancellor" → "Pope 1: The Schizo"
|
||||||
|
2: "Pope 2: President" → "Pope 2: The Despot"
|
||||||
|
3: "Pope 3: Tsar" → "Pope 3: The Capitalist"
|
||||||
|
4: "Pope 4: Chairman" → "Pope 4: The Fascist"
|
||||||
|
5: "Pope 5: Emperor" → "Pope 5: The War Machine"
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
NEW_NAMES = {
|
||||||
|
0: ("The Nomad", "the-nomad"),
|
||||||
|
1: ("Pope 1: The Schizo", "pope-1-the-schizo"),
|
||||||
|
2: ("Pope 2: The Despot", "pope-2-the-despot"),
|
||||||
|
3: ("Pope 3: The Capitalist", "pope-3-the-capitalist"),
|
||||||
|
4: ("Pope 4: The Fascist", "pope-4-the-fascist"),
|
||||||
|
5: ("Pope 5: The War Machine","pope-5-the-war-machine"),
|
||||||
|
}
|
||||||
|
|
||||||
|
OLD_NAMES = {
|
||||||
|
0: ("The Schiz", "the-schiz"),
|
||||||
|
1: ("Pope 1: Chancellor", "pope-1-chancellor"),
|
||||||
|
2: ("Pope 2: President", "pope-2-president"),
|
||||||
|
3: ("Pope 3: Tsar", "pope-3-tsar"),
|
||||||
|
4: ("Pope 4: Chairman", "pope-4-chairman"),
|
||||||
|
5: ("Pope 5: Emperor", "pope-5-emperor"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def rename_forward(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
|
||||||
|
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||||
|
if not earthman:
|
||||||
|
return
|
||||||
|
|
||||||
|
for number, (new_name, new_slug) in NEW_NAMES.items():
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=number
|
||||||
|
).update(name=new_name, slug=new_slug)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_reverse(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
|
||||||
|
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||||
|
if not earthman:
|
||||||
|
return
|
||||||
|
|
||||||
|
for number, (old_name, old_slug) in OLD_NAMES.items():
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=number
|
||||||
|
).update(name=old_name, slug=old_slug)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0018_alter_tarotcard_suit"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
|
||||||
|
]
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Data migration: rename Pope cards 2–5 in the Earthman deck.
|
||||||
|
|
||||||
|
2: "Pope 2: The Despot" → "Pope 2: The Occultist"
|
||||||
|
3: "Pope 3: The Capitalist" → "Pope 3: The Despot"
|
||||||
|
4: "Pope 4: The Fascist" → "Pope 4: The Capitalist"
|
||||||
|
5: "Pope 5: The War Machine" → "Pope 5: The Fascist"
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
NEW_NAMES = {
|
||||||
|
2: ("Pope 2: The Occultist", "pope-2-the-occultist"),
|
||||||
|
3: ("Pope 3: The Despot", "pope-3-the-despot"),
|
||||||
|
4: ("Pope 4: The Capitalist","pope-4-the-capitalist"),
|
||||||
|
5: ("Pope 5: The Fascist", "pope-5-the-fascist"),
|
||||||
|
}
|
||||||
|
|
||||||
|
OLD_NAMES = {
|
||||||
|
2: ("Pope 2: The Despot", "pope-2-the-despot"),
|
||||||
|
3: ("Pope 3: The Capitalist", "pope-3-the-capitalist"),
|
||||||
|
4: ("Pope 4: The Fascist", "pope-4-the-fascist"),
|
||||||
|
5: ("Pope 5: The War Machine", "pope-5-the-war-machine"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def rename_forward(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
|
||||||
|
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||||
|
if not earthman:
|
||||||
|
return
|
||||||
|
|
||||||
|
for number, (new_name, new_slug) in NEW_NAMES.items():
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=number
|
||||||
|
).update(name=new_name, slug=new_slug)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_reverse(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
|
||||||
|
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||||
|
if not earthman:
|
||||||
|
return
|
||||||
|
|
||||||
|
for number, (old_name, old_slug) in OLD_NAMES.items():
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=number
|
||||||
|
).update(name=old_name, slug=old_slug)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0019_rename_earthman_schiz_and_popes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
|
||||||
|
]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
Data migration: rename/update six Earthman Major Arcana cards.
|
||||||
|
|
||||||
|
13 name: "Death" → "King Death & the Cosmic Tree"
|
||||||
|
14 name: "The Traitor" → "The Great Hunt"
|
||||||
|
15 correspondence: "The Tower / La Torre" → "The House of the Devil / Inferno"
|
||||||
|
16 correspondence: "Purgatorio" → "The Tower / La Torre / Purgatorio"
|
||||||
|
50 name/slug: "The Eagle" → "The Mould of Man"
|
||||||
|
51 name/slug: "Divine Calculus" → "The Eagle"
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
FORWARD = {
|
||||||
|
13: dict(name="King Death & the Cosmic Tree", slug="king-death-and-the-cosmic-tree"),
|
||||||
|
14: dict(name="The Great Hunt", slug="the-great-hunt"),
|
||||||
|
15: dict(correspondence="The House of the Devil / Inferno"),
|
||||||
|
16: dict(correspondence="The Tower / La Torre / Purgatorio"),
|
||||||
|
50: dict(name="The Mould of Man", slug="the-mould-of-man"),
|
||||||
|
51: dict(name="The Eagle", slug="the-eagle"),
|
||||||
|
}
|
||||||
|
|
||||||
|
REVERSE = {
|
||||||
|
13: dict(name="Death", slug="death-em"),
|
||||||
|
14: dict(name="The Traitor", slug="the-traitor"),
|
||||||
|
15: dict(correspondence="The Tower / La Torre"),
|
||||||
|
16: dict(correspondence="Purgatorio"),
|
||||||
|
50: dict(name="The Eagle", slug="the-eagle"),
|
||||||
|
51: dict(name="Divine Calculus",slug="divine-calculus"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply(changes):
|
||||||
|
def fn(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||||
|
if not earthman:
|
||||||
|
return
|
||||||
|
# Process in sorted order so card 50 vacates "the-eagle" slug before card 51 claims it.
|
||||||
|
for number in sorted(changes):
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=number
|
||||||
|
).update(**changes[number])
|
||||||
|
return fn
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0020_rename_earthman_pope_cards_2_5"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(apply(FORWARD), reverse_code=apply(REVERSE)),
|
||||||
|
]
|
||||||
31
src/apps/epic/migrations/0022_sig_reservation.py
Normal file
31
src/apps/epic/migrations/0022_sig_reservation.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-06 00:02
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0021_rename_earthman_major_arcana_batch_2'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='SigReservation',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('role', models.CharField(max_length=2)),
|
||||||
|
('polarity', models.CharField(choices=[('levity', 'Levity'), ('gravity', 'Gravity')], max_length=7)),
|
||||||
|
('reserved_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to='epic.tarotcard')),
|
||||||
|
('gamer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to='epic.room')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'constraints': [models.UniqueConstraint(fields=('room', 'gamer'), name='one_sig_reservation_per_gamer_per_room'), models.UniqueConstraint(fields=('room', 'card', 'polarity'), name='one_reservation_per_card_per_polarity_per_room')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-06 02:22
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0022_sig_reservation'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='tarotcard',
|
||||||
|
name='icon',
|
||||||
|
field=models.CharField(blank=True, default='', max_length=50),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tarotcard',
|
||||||
|
name='arcana',
|
||||||
|
field=models.CharField(choices=[('MAJOR', 'Major Arcana'), ('MINOR', 'Minor Arcana'), ('MIDDLE', 'Middle Arcana')], max_length=6),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tarotcard',
|
||||||
|
name='suit',
|
||||||
|
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('CROWNS', 'Crowns')], max_length=10, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""
|
||||||
|
Data migration: rename Earthman 4th-suit cards from PENTACLES → CROWNS.
|
||||||
|
|
||||||
|
Updates for every Earthman card where suit="PENTACLES":
|
||||||
|
- suit: "PENTACLES" → "CROWNS"
|
||||||
|
- name: " of Pentacles" → " of Crowns"
|
||||||
|
- slug: "pentacles" → "crowns"
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def pentacles_to_crowns(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||||
|
if not earthman:
|
||||||
|
return
|
||||||
|
for card in TarotCard.objects.filter(deck_variant=earthman, suit="PENTACLES"):
|
||||||
|
card.suit = "CROWNS"
|
||||||
|
card.name = card.name.replace(" of Pentacles", " of Crowns")
|
||||||
|
card.slug = card.slug.replace("pentacles", "crowns")
|
||||||
|
card.save(update_fields=["suit", "name", "slug"])
|
||||||
|
|
||||||
|
|
||||||
|
def crowns_to_pentacles(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||||
|
if not earthman:
|
||||||
|
return
|
||||||
|
for card in TarotCard.objects.filter(deck_variant=earthman, suit="CROWNS"):
|
||||||
|
card.suit = "PENTACLES"
|
||||||
|
card.name = card.name.replace(" of Crowns", " of Pentacles")
|
||||||
|
card.slug = card.slug.replace("crowns", "pentacles")
|
||||||
|
card.save(update_fields=["suit", "name", "slug"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0023_tarotcard_icon_alter_tarotcard_arcana_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(pentacles_to_crowns, reverse_code=crowns_to_pentacles),
|
||||||
|
]
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
Data migration: Earthman deck — court cards and major arcana icons.
|
||||||
|
|
||||||
|
1. Court cards (numbers 11–14, all suits): arcana "MINOR" → "MIDDLE"
|
||||||
|
2. Major arcana icons (stored in TarotCard.icon):
|
||||||
|
0 (Nomad) → fa-hat-cowboy-side
|
||||||
|
1 (Schizo) → fa-hat-wizard
|
||||||
|
2–51 (rest) → fa-hand-dots
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
MAJOR_ICONS = {
|
||||||
|
0: "fa-hat-cowboy-side",
|
||||||
|
1: "fa-hat-wizard",
|
||||||
|
}
|
||||||
|
DEFAULT_MAJOR_ICON = "fa-hand-dots"
|
||||||
|
|
||||||
|
|
||||||
|
def forward(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||||
|
if not earthman:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Court cards → MIDDLE
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MINOR", number__in=[11, 12, 13, 14]
|
||||||
|
).update(arcana="MIDDLE")
|
||||||
|
|
||||||
|
# Major arcana icons
|
||||||
|
for card in TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR"):
|
||||||
|
card.icon = MAJOR_ICONS.get(card.number, DEFAULT_MAJOR_ICON)
|
||||||
|
card.save(update_fields=["icon"])
|
||||||
|
|
||||||
|
|
||||||
|
def backward(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||||
|
if not earthman:
|
||||||
|
return
|
||||||
|
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MIDDLE", number__in=[11, 12, 13, 14]
|
||||||
|
).update(arcana="MINOR")
|
||||||
|
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR"
|
||||||
|
).update(icon="")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0024_earthman_pentacles_to_crowns"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forward, reverse_code=backward),
|
||||||
|
]
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
Data migration — Earthman deck:
|
||||||
|
1. Rename three suit codes (and card names) for Earthman cards:
|
||||||
|
WANDS → BRANDS (Wands → Brands)
|
||||||
|
CUPS → GRAILS (Cups → Grails)
|
||||||
|
SWORDS → BLADES (Swords → Blades)
|
||||||
|
CROWNS stays CROWNS.
|
||||||
|
2. Copy keywords_upright / keywords_reversed from the Fiorentine Minchiate
|
||||||
|
deck to corresponding Earthman cards:
|
||||||
|
• Major: explicit number-to-number map based on card correspondences.
|
||||||
|
• Minor/Middle: same number, suit mapped (BRANDS→WANDS, GRAILS→CUPS,
|
||||||
|
BLADES→SWORDS, CROWNS→PENTACLES). Cards with no Fiorentine counterpart
|
||||||
|
stay with empty keyword lists.
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
# ── 1. Suit rename map ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SUIT_RENAMES = {
|
||||||
|
"WANDS": "BRANDS",
|
||||||
|
"CUPS": "GRAILS",
|
||||||
|
"SWORDS": "BLADES",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 2. Major arcana: Earthman number → Fiorentine number ─────────────────────
|
||||||
|
# Cards without a Fiorentine counterpart are omitted (keywords stay empty).
|
||||||
|
|
||||||
|
MAJOR_KEYWORD_MAP = {
|
||||||
|
0: 0, # The Schiz → The Fool
|
||||||
|
1: 1, # Pope I (President) → The Magician
|
||||||
|
2: 2, # Pope II (Tsar) → The High Priestess
|
||||||
|
3: 3, # Pope III (Chairman) → The Empress
|
||||||
|
4: 4, # Pope IV (Emperor) → The Emperor
|
||||||
|
5: 5, # Pope V (Chancellor) → The Hierophant
|
||||||
|
6: 8, # Virtue VI (Controlled Folly) → Strength
|
||||||
|
7: 11, # Virtue VII (Not-Doing) → Justice
|
||||||
|
8: 14, # Virtue VIII (Losing Self-Importance) → Temperance
|
||||||
|
# 9: Prudence — no Fiorentine equivalent
|
||||||
|
10: 10, # Wheel of Fortune → Wheel of Fortune
|
||||||
|
11: 7, # The Junkboat → The Chariot
|
||||||
|
12: 12, # The Junkman → The Hanged Man
|
||||||
|
13: 13, # Death → Death
|
||||||
|
14: 15, # The Traitor → The Devil
|
||||||
|
15: 16, # Disco Inferno → The Tower
|
||||||
|
# 16: Torre Terrestre (Purgatory) — no equivalent
|
||||||
|
# 17: Fantasia Celestia (Paradise) — no equivalent
|
||||||
|
18: 6, # Virtue XVIII (Stalking) → The Lovers
|
||||||
|
# 19: Virtue XIX (Intent / Hope) — no equivalent
|
||||||
|
# 20: Virtue XX (Dreaming / Faith)— no equivalent
|
||||||
|
# 21–38: Classical Elements + Zodiac — no equivalents
|
||||||
|
39: 17, # Wanderer XXXIX (Polestar) → The Star
|
||||||
|
40: 18, # Wanderer XL (Antichthon) → The Moon
|
||||||
|
41: 19, # Wanderer XLI (Corestar) → The Sun
|
||||||
|
# 42–49: Planets + The Binary — no equivalents
|
||||||
|
50: 20, # The Eagle → Judgement
|
||||||
|
51: 21, # Divine Calculus → The World
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 3. Minor suit map: Earthman (post-rename) → Fiorentine ───────────────────
|
||||||
|
|
||||||
|
MINOR_SUIT_MAP = {
|
||||||
|
"BRANDS": "WANDS",
|
||||||
|
"GRAILS": "CUPS",
|
||||||
|
"BLADES": "SWORDS",
|
||||||
|
"CROWNS": "PENTACLES",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def forward(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
|
||||||
|
try:
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
|
||||||
|
except DeckVariant.DoesNotExist:
|
||||||
|
return # decks not seeded — nothing to do
|
||||||
|
|
||||||
|
# ── Step 1: rename Earthman suit codes + card names ───────────────────────
|
||||||
|
for old_suit, new_suit in SUIT_RENAMES.items():
|
||||||
|
old_display = old_suit.capitalize() # e.g. "Wands"
|
||||||
|
new_display = new_suit.capitalize() # e.g. "Brands"
|
||||||
|
cards = TarotCard.objects.filter(deck_variant=earthman, suit=old_suit)
|
||||||
|
for card in cards:
|
||||||
|
card.name = card.name.replace(f" of {old_display}", f" of {new_display}")
|
||||||
|
card.suit = new_suit
|
||||||
|
card.save()
|
||||||
|
|
||||||
|
# ── Step 2: copy major arcana keywords ───────────────────────────────────
|
||||||
|
fio_major = {
|
||||||
|
card.number: card
|
||||||
|
for card in TarotCard.objects.filter(deck_variant=fiorentine, arcana="MAJOR")
|
||||||
|
}
|
||||||
|
for em_num, fio_num in MAJOR_KEYWORD_MAP.items():
|
||||||
|
fio_card = fio_major.get(fio_num)
|
||||||
|
if not fio_card:
|
||||||
|
continue
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=em_num
|
||||||
|
).update(
|
||||||
|
keywords_upright=fio_card.keywords_upright,
|
||||||
|
keywords_reversed=fio_card.keywords_reversed,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Step 3: copy minor/middle arcana keywords ─────────────────────────────
|
||||||
|
for em_suit, fio_suit in MINOR_SUIT_MAP.items():
|
||||||
|
fio_by_number = {
|
||||||
|
card.number: card
|
||||||
|
for card in TarotCard.objects.filter(deck_variant=fiorentine, suit=fio_suit)
|
||||||
|
}
|
||||||
|
for em_card in TarotCard.objects.filter(deck_variant=earthman, suit=em_suit):
|
||||||
|
fio_card = fio_by_number.get(em_card.number)
|
||||||
|
if fio_card:
|
||||||
|
em_card.keywords_upright = fio_card.keywords_upright
|
||||||
|
em_card.keywords_reversed = fio_card.keywords_reversed
|
||||||
|
em_card.save()
|
||||||
|
|
||||||
|
|
||||||
|
def reverse(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
|
||||||
|
try:
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
except DeckVariant.DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reverse suit renames
|
||||||
|
reverse_renames = {new: old for old, new in SUIT_RENAMES.items()}
|
||||||
|
for new_suit, old_suit in reverse_renames.items():
|
||||||
|
new_display = new_suit.capitalize()
|
||||||
|
old_display = old_suit.capitalize()
|
||||||
|
cards = TarotCard.objects.filter(deck_variant=earthman, suit=new_suit)
|
||||||
|
for card in cards:
|
||||||
|
card.name = card.name.replace(f" of {new_display}", f" of {old_display}")
|
||||||
|
card.suit = old_suit
|
||||||
|
card.save()
|
||||||
|
|
||||||
|
# Clear all Earthman keywords
|
||||||
|
TarotCard.objects.filter(deck_variant=earthman).update(
|
||||||
|
keywords_upright=[],
|
||||||
|
keywords_reversed=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0025_earthman_middle_arcana_and_major_icons"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forward, reverse_code=reverse),
|
||||||
|
]
|
||||||
65
src/apps/epic/migrations/0027_tarotcard_cautions.py
Normal file
65
src/apps/epic/migrations/0027_tarotcard_cautions.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
Schema + data migration:
|
||||||
|
1. Add `cautions` JSONField (list, default=[]) to TarotCard.
|
||||||
|
2. Seed The Schizo (Earthman MAJOR #1) with 4 rival-interaction cautions.
|
||||||
|
All other cards default to [] — the UI shows a placeholder when empty.
|
||||||
|
"""
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
SCHIZO_CAUTIONS = [
|
||||||
|
'This card will reverse into <span class="card-ref">The Pervert</span> when it'
|
||||||
|
' comes under dominion of <span class="card-ref">The Occultist</span>, which in turn'
|
||||||
|
' reverses into <span class="card-ref">Pestilence</span>.',
|
||||||
|
|
||||||
|
'This card will reverse into <span class="card-ref">The Paranoiac</span> when it'
|
||||||
|
' comes under dominion of <span class="card-ref">The Despot</span>, which in turn'
|
||||||
|
' reverses into <span class="card-ref">War</span>.',
|
||||||
|
|
||||||
|
'This card will reverse into <span class="card-ref">The Neurotic</span> when it'
|
||||||
|
' comes under dominion of <span class="card-ref">The Capitalist</span>, which in turn'
|
||||||
|
' reverses into <span class="card-ref">Famine</span>.',
|
||||||
|
|
||||||
|
'This card will reverse into <span class="card-ref">The Suicidal</span> when it'
|
||||||
|
' comes under dominion of <span class="card-ref">The Fascist</span>, which in turn'
|
||||||
|
' reverses into <span class="card-ref">Death</span>.',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def seed_schizo_cautions(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
try:
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
except DeckVariant.DoesNotExist:
|
||||||
|
return
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=1
|
||||||
|
).update(cautions=SCHIZO_CAUTIONS)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_schizo_cautions(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
try:
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
except DeckVariant.DoesNotExist:
|
||||||
|
return
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=1
|
||||||
|
).update(cautions=[])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0026_earthman_suit_renames_and_keywords"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="tarotcard",
|
||||||
|
name="cautions",
|
||||||
|
field=models.JSONField(default=list),
|
||||||
|
),
|
||||||
|
migrations.RunPython(seed_schizo_cautions, reverse_code=clear_schizo_cautions),
|
||||||
|
]
|
||||||
18
src/apps/epic/migrations/0028_alter_tarotcard_suit.py
Normal file
18
src/apps/epic/migrations/0028_alter_tarotcard_suit.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-07 03:09
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0027_tarotcard_cautions'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tarotcard',
|
||||||
|
name='suit',
|
||||||
|
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('CROWNS', 'Crowns'), ('BRANDS', 'Brands'), ('GRAILS', 'Grails'), ('BLADES', 'Blades')], max_length=10, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
61
src/apps/epic/migrations/0029_fix_schizo_cautions.py
Normal file
61
src/apps/epic/migrations/0029_fix_schizo_cautions.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""
|
||||||
|
Data fix: clear Schizo cautions from The Nomad (number=0) if present,
|
||||||
|
and ensure they land on The Schizo (number=1).
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
SCHIZO_CAUTIONS = [
|
||||||
|
'This card will reverse into <span class="card-ref">I. The Pervert</span> when it'
|
||||||
|
' comes under dominion of <span class="card-ref">II. The Occultist</span>, which in turn'
|
||||||
|
' reverses into <span class="card-ref">II. Pestilence</span>.',
|
||||||
|
|
||||||
|
'This card will reverse into <span class="card-ref">I. The Paranoiac</span> when it'
|
||||||
|
' comes under dominion of <span class="card-ref">III. The Despot</span>, which in turn'
|
||||||
|
' reverses into <span class="card-ref">III. War</span>.',
|
||||||
|
|
||||||
|
'This card will reverse into <span class="card-ref">I. The Neurotic</span> when it'
|
||||||
|
' comes under dominion of <span class="card-ref">IV. The Capitalist</span>, which in turn'
|
||||||
|
' reverses into <span class="card-ref">IV. Famine</span>.',
|
||||||
|
|
||||||
|
'This card will reverse into <span class="card-ref">I. The Suicidal</span> when it'
|
||||||
|
' comes under dominion of <span class="card-ref">V. The Fascist</span>, which in turn'
|
||||||
|
' reverses into <span class="card-ref">V. Death</span>.',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def forward(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
try:
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
except DeckVariant.DoesNotExist:
|
||||||
|
return
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=0
|
||||||
|
).update(cautions=[])
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=1
|
||||||
|
).update(cautions=SCHIZO_CAUTIONS)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
try:
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
except DeckVariant.DoesNotExist:
|
||||||
|
return
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=1
|
||||||
|
).update(cautions=[])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0028_alter_tarotcard_suit"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forward, reverse_code=reverse),
|
||||||
|
]
|
||||||
23
src/apps/epic/migrations/0030_sigreservation_seat_fk.py
Normal file
23
src/apps/epic/migrations/0030_sigreservation_seat_fk.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0029_fix_schizo_cautions'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sigreservation',
|
||||||
|
name='seat',
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.SET_NULL,
|
||||||
|
related_name='sig_reservation',
|
||||||
|
to='epic.tableseat',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -3,6 +3,7 @@ import uuid
|
|||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models import UniqueConstraint
|
||||||
from django.db.models.signals import post_save
|
from django.db.models.signals import post_save
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -148,6 +149,9 @@ def debit_token(user, slot, token):
|
|||||||
room.save()
|
room.save()
|
||||||
|
|
||||||
|
|
||||||
|
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||||||
|
|
||||||
|
|
||||||
class TableSeat(models.Model):
|
class TableSeat(models.Model):
|
||||||
PC = "PC"
|
PC = "PC"
|
||||||
BC = "BC"
|
BC = "BC"
|
||||||
@@ -174,6 +178,10 @@ class TableSeat(models.Model):
|
|||||||
role = models.CharField(max_length=2, choices=ROLE_CHOICES, null=True, blank=True)
|
role = models.CharField(max_length=2, choices=ROLE_CHOICES, null=True, blank=True)
|
||||||
role_revealed = models.BooleanField(default=False)
|
role_revealed = models.BooleanField(default=False)
|
||||||
seat_position = models.IntegerField(null=True, blank=True)
|
seat_position = models.IntegerField(null=True, blank=True)
|
||||||
|
significator = models.ForeignKey(
|
||||||
|
"TarotCard", null=True, blank=True,
|
||||||
|
on_delete=models.SET_NULL, related_name="significator_seats",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DeckVariant(models.Model):
|
class DeckVariant(models.Model):
|
||||||
@@ -197,22 +205,30 @@ class DeckVariant(models.Model):
|
|||||||
class TarotCard(models.Model):
|
class TarotCard(models.Model):
|
||||||
MAJOR = "MAJOR"
|
MAJOR = "MAJOR"
|
||||||
MINOR = "MINOR"
|
MINOR = "MINOR"
|
||||||
|
MIDDLE = "MIDDLE" # Earthman court cards (M/J/Q/K)
|
||||||
ARCANA_CHOICES = [
|
ARCANA_CHOICES = [
|
||||||
(MAJOR, "Major Arcana"),
|
(MAJOR, "Major Arcana"),
|
||||||
(MINOR, "Minor Arcana"),
|
(MINOR, "Minor Arcana"),
|
||||||
|
(MIDDLE, "Middle Arcana"),
|
||||||
]
|
]
|
||||||
|
|
||||||
WANDS = "WANDS"
|
WANDS = "WANDS"
|
||||||
CUPS = "CUPS"
|
CUPS = "CUPS"
|
||||||
SWORDS = "SWORDS"
|
SWORDS = "SWORDS"
|
||||||
PENTACLES = "PENTACLES" # Fiorentine 4th suit
|
PENTACLES = "PENTACLES" # Fiorentine 4th suit
|
||||||
COINS = "COINS" # Earthman 4th suit (Ossum / Stone)
|
CROWNS = "CROWNS" # Earthman 4th suit
|
||||||
|
BRANDS = "BRANDS" # Earthman Wands
|
||||||
|
GRAILS = "GRAILS" # Earthman Cups
|
||||||
|
BLADES = "BLADES" # Earthman Swords
|
||||||
SUIT_CHOICES = [
|
SUIT_CHOICES = [
|
||||||
(WANDS, "Wands"),
|
(WANDS, "Wands"),
|
||||||
(CUPS, "Cups"),
|
(CUPS, "Cups"),
|
||||||
(SWORDS, "Swords"),
|
(SWORDS, "Swords"),
|
||||||
(PENTACLES, "Pentacles"),
|
(PENTACLES, "Pentacles"),
|
||||||
(COINS, "Coins"),
|
(CROWNS, "Crowns"),
|
||||||
|
(BRANDS, "Brands"),
|
||||||
|
(GRAILS, "Grails"),
|
||||||
|
(BLADES, "Blades"),
|
||||||
]
|
]
|
||||||
|
|
||||||
deck_variant = models.ForeignKey(
|
deck_variant = models.ForeignKey(
|
||||||
@@ -220,14 +236,16 @@ class TarotCard(models.Model):
|
|||||||
on_delete=models.CASCADE, related_name="cards",
|
on_delete=models.CASCADE, related_name="cards",
|
||||||
)
|
)
|
||||||
name = models.CharField(max_length=200)
|
name = models.CharField(max_length=200)
|
||||||
arcana = models.CharField(max_length=5, choices=ARCANA_CHOICES)
|
arcana = models.CharField(max_length=6, choices=ARCANA_CHOICES)
|
||||||
suit = models.CharField(max_length=10, choices=SUIT_CHOICES, null=True, blank=True)
|
suit = models.CharField(max_length=10, choices=SUIT_CHOICES, null=True, blank=True)
|
||||||
|
icon = models.CharField(max_length=50, blank=True, default='') # FA icon override (e.g. major arcana)
|
||||||
number = models.IntegerField() # 0–21 major (Fiorentine); 0–51 major (Earthman); 1–14 minor
|
number = models.IntegerField() # 0–21 major (Fiorentine); 0–51 major (Earthman); 1–14 minor
|
||||||
slug = models.SlugField(max_length=120)
|
slug = models.SlugField(max_length=120)
|
||||||
correspondence = models.CharField(max_length=200, blank=True) # standard / Italian equivalent
|
correspondence = models.CharField(max_length=200, blank=True) # standard / Italian equivalent
|
||||||
group = models.CharField(max_length=100, blank=True) # Earthman major grouping
|
group = models.CharField(max_length=100, blank=True) # Earthman major grouping
|
||||||
keywords_upright = models.JSONField(default=list)
|
keywords_upright = models.JSONField(default=list)
|
||||||
keywords_reversed = models.JSONField(default=list)
|
keywords_reversed = models.JSONField(default=list)
|
||||||
|
cautions = models.JSONField(default=list)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["deck_variant", "arcana", "suit", "number"]
|
ordering = ["deck_variant", "arcana", "suit", "number"]
|
||||||
@@ -269,16 +287,26 @@ class TarotCard(models.Model):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def suit_icon(self):
|
def suit_icon(self):
|
||||||
|
if self.icon:
|
||||||
|
return self.icon
|
||||||
if self.arcana == self.MAJOR:
|
if self.arcana == self.MAJOR:
|
||||||
return ''
|
return ''
|
||||||
return {
|
return {
|
||||||
self.WANDS: 'fa-wand-sparkles',
|
self.WANDS: 'fa-wand-sparkles',
|
||||||
self.CUPS: 'fa-trophy',
|
self.CUPS: 'fa-trophy',
|
||||||
self.SWORDS: 'fa-gun',
|
self.SWORDS: 'fa-gun',
|
||||||
self.COINS: 'fa-star',
|
|
||||||
self.PENTACLES: 'fa-star',
|
self.PENTACLES: 'fa-star',
|
||||||
|
self.CROWNS: 'fa-crown',
|
||||||
|
self.BRANDS: 'fa-wand-sparkles',
|
||||||
|
self.GRAILS: 'fa-trophy',
|
||||||
|
self.BLADES: 'fa-gun',
|
||||||
}.get(self.suit, '')
|
}.get(self.suit, '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cautions_json(self):
|
||||||
|
import json
|
||||||
|
return json.dumps(self.cautions)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
@@ -318,3 +346,122 @@ class TarotDeck(models.Model):
|
|||||||
"""Reset the deck so all variant cards are available again."""
|
"""Reset the deck so all variant cards are available again."""
|
||||||
self.drawn_card_ids = []
|
self.drawn_card_ids = []
|
||||||
self.save(update_fields=["drawn_card_ids"])
|
self.save(update_fields=["drawn_card_ids"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── SigReservation — provisional card hold during SIG_SELECT ──────────────────
|
||||||
|
|
||||||
|
class SigReservation(models.Model):
|
||||||
|
LEVITY = 'levity'
|
||||||
|
GRAVITY = 'gravity'
|
||||||
|
POLARITY_CHOICES = [(LEVITY, 'Levity'), (GRAVITY, 'Gravity')]
|
||||||
|
|
||||||
|
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name='sig_reservations')
|
||||||
|
gamer = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sig_reservations'
|
||||||
|
)
|
||||||
|
seat = models.ForeignKey(
|
||||||
|
'TableSeat', null=True, blank=True,
|
||||||
|
on_delete=models.SET_NULL, related_name='sig_reservation',
|
||||||
|
)
|
||||||
|
card = models.ForeignKey(
|
||||||
|
'TarotCard', on_delete=models.CASCADE, related_name='sig_reservations'
|
||||||
|
)
|
||||||
|
role = models.CharField(max_length=2)
|
||||||
|
polarity = models.CharField(max_length=7, choices=POLARITY_CHOICES)
|
||||||
|
reserved_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
constraints = [
|
||||||
|
UniqueConstraint(
|
||||||
|
fields=['room', 'gamer'],
|
||||||
|
name='one_sig_reservation_per_gamer_per_room',
|
||||||
|
),
|
||||||
|
UniqueConstraint(
|
||||||
|
fields=['room', 'card', 'polarity'],
|
||||||
|
name='one_reservation_per_card_per_polarity_per_room',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Significator deck helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def sig_deck_cards(room):
|
||||||
|
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
|
||||||
|
|
||||||
|
PC/BC pair → BRANDS/WANDS + CROWNS Middle Arcana court cards (11–14): 8 unique
|
||||||
|
SC/AC pair → BLADES/SWORDS + GRAILS/CUPS Middle Arcana court cards (11–14): 8 unique
|
||||||
|
NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique
|
||||||
|
Total: 18 unique × 2 (levity + gravity piles) = 36 cards.
|
||||||
|
"""
|
||||||
|
deck_variant = room.owner.equipped_deck
|
||||||
|
if deck_variant is None:
|
||||||
|
return []
|
||||||
|
wands_crowns = list(TarotCard.objects.filter(
|
||||||
|
deck_variant=deck_variant,
|
||||||
|
arcana=TarotCard.MIDDLE,
|
||||||
|
suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS],
|
||||||
|
number__in=[11, 12, 13, 14],
|
||||||
|
))
|
||||||
|
swords_cups = list(TarotCard.objects.filter(
|
||||||
|
deck_variant=deck_variant,
|
||||||
|
arcana=TarotCard.MIDDLE,
|
||||||
|
suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS],
|
||||||
|
number__in=[11, 12, 13, 14],
|
||||||
|
))
|
||||||
|
major = list(TarotCard.objects.filter(
|
||||||
|
deck_variant=deck_variant,
|
||||||
|
arcana=TarotCard.MAJOR,
|
||||||
|
number__in=[0, 1],
|
||||||
|
))
|
||||||
|
unique_cards = wands_crowns + swords_cups + major # 18 unique
|
||||||
|
return unique_cards + unique_cards # × 2 = 36
|
||||||
|
|
||||||
|
|
||||||
|
def _sig_unique_cards(room):
|
||||||
|
"""Return the 18 unique TarotCard objects that form one sig pile."""
|
||||||
|
deck_variant = room.owner.equipped_deck
|
||||||
|
if deck_variant is None:
|
||||||
|
return []
|
||||||
|
wands_crowns = list(TarotCard.objects.filter(
|
||||||
|
deck_variant=deck_variant,
|
||||||
|
arcana=TarotCard.MIDDLE,
|
||||||
|
suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS],
|
||||||
|
number__in=[11, 12, 13, 14],
|
||||||
|
))
|
||||||
|
swords_cups = list(TarotCard.objects.filter(
|
||||||
|
deck_variant=deck_variant,
|
||||||
|
arcana=TarotCard.MIDDLE,
|
||||||
|
suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS],
|
||||||
|
number__in=[11, 12, 13, 14],
|
||||||
|
))
|
||||||
|
major = list(TarotCard.objects.filter(
|
||||||
|
deck_variant=deck_variant,
|
||||||
|
arcana=TarotCard.MAJOR,
|
||||||
|
number__in=[0, 1],
|
||||||
|
))
|
||||||
|
return wands_crowns + swords_cups + major
|
||||||
|
|
||||||
|
|
||||||
|
def levity_sig_cards(room):
|
||||||
|
"""The 18 cards available to the levity group (PC/NC/SC)."""
|
||||||
|
return _sig_unique_cards(room)
|
||||||
|
|
||||||
|
|
||||||
|
def gravity_sig_cards(room):
|
||||||
|
"""The 18 cards available to the gravity group (BC/EC/AC)."""
|
||||||
|
return _sig_unique_cards(room)
|
||||||
|
|
||||||
|
|
||||||
|
def sig_seat_order(room):
|
||||||
|
"""Return TableSeats in canonical PC→NC→EC→SC→AC→BC order."""
|
||||||
|
_order = {r: i for i, r in enumerate(SIG_SEAT_ORDER)}
|
||||||
|
seats = list(room.table_seats.all())
|
||||||
|
return sorted(seats, key=lambda s: _order.get(s.role, 99))
|
||||||
|
|
||||||
|
|
||||||
|
def active_sig_seat(room):
|
||||||
|
"""Return the first seat without a significator in canonical order, or None."""
|
||||||
|
for seat in sig_seat_order(room):
|
||||||
|
if seat.significator_id is None:
|
||||||
|
return seat
|
||||||
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #354a9c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #381507;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
stroke-width: 2.75px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3, .cls-4 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #381507;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: #4f66d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: #4258b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-7 {
|
||||||
|
fill: #3d180d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-8 {
|
||||||
|
fill: #3a1709;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
stroke-width: 2.2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-5" d="M185.31,203.37l.12.54,1.16,5.4-2.22.49c-.08,8.17-.62,15.76-1.54,24.08-.26,2.33.18,7.31,3.04,7.58,4.78.44,9.33-1.88,14.1-2.1,1.59-.07,3.44-.05,4.69-.32l.98,9.54c.24,2.25,2.12,2.5,3.74,3.16,1.99.81,2.31,3.55,3.46,5.16l3.75,5.23c.62.86,1.45,1.66,2.51,1.36-.12,2.28.15,4.61.11,7.14l-.44,1.49c-1.47-.84-2.37,1.63-3.55,1.77l-16.08,1.97-21.66,2.39c4.99,1.47,9.61,1.45,14.55,2.03l23.5,2.78c.22,1.66.49.84,1.47.31,1.01,6.14,2.22,12.45,2.53,19.3,1.2,4.17.5,8.59-3.88,10.35l-3.91,2.35c.65.86,1.46,1.29,1.81,2.27,2.77,1.35,3.4,4.57,3.09,7.75,2.44,5.7,2.42,11.51,1.87,17.57l-.91,9.96c-.14,1.55-1.69,1.51-2.77,1.41l-17.57-1.6c-4.26-.39-8.71.46-12.16-2.84l-5.53-7.04c-.25.2-.35.47-.36.58,0-.11.11-.38.36-.58-.25.2-.35.47-.36.58-.28,3.07-.81,5.92-2.85,8.97l-21.21,1.95c-.12-1.76-.26-3.48-1.01-5.15l-4.42-9.91-3.75-25.3c-.14-.97-.63-1.8-1.07-2.16.43-1.73-.03-4.16-.58-6.18-1.9-4.98-3.59-9.83-4.83-15.35.18-.51-1.33-1.69-.39-2.35l1.62-1.16c1-.71-.63-1.33-.17-2.07.4-.66,1.75-.46,1.97-1.2.33-1.08-.35-2.18-1.64-2.16l-4.2.07-9.67-35.31c-.2-.75-.95-.91-1.34-1.05-.54-.19-1.5,0-1.53.93-.02.66-.64,1.72-1.09,2.78-1.2-1.34-4.74-2.35-5.46-.22l-12.57,37.25-7.39,1.96-1.8.57c-.58.18-.63,2.12,0,2.14,1.81.06,3.73-1.13,5.06.44-1.62.37-4.59.86-3.54,2.77.86,1.55,3.62.48,4.95.31l-4.78,15.62c-.24.78.03,1.76.57,2.34.4.43,1.33.72,2.04-.13,1.08-1.29,1.25-3.2,1.82-4.45.97-.38,2.22.37.73.97-.13,1.21-.23,2.19.18,3.14.23.53.84.92,2.01.61,2.71-6.4,4.7-12.72,6.8-19.73l18.11-2.9,5.96,21.86c.44,1.61,1.47,1.6,2.91,1.32,1.12-.22.51-1.69.51-2.99v-1.31c0-.37.34-.39,1.2-.31-1.04,1.12-.03,2.08.5,3.48.36.96,1.5,1.25,2.74,1.23l-.5,20.05c-.09,3.58-.56,6.95-.22,10.34l-21.98.14-2.92-9.49c2.68-4.69,5.77-9.41,3.04-13.61l-6.91,1.61c-1.59.37.86,2.08.68,2.8l-1.98,7.57c-.27,1.01.11,2.26-.46,3.03-1.37,1.88-6.3,1.61-9.49.15-1.3-2.98,1.24-7.91.16-13.45-.44-2.25-3.52-1.29-5.21-1.15l-8.08.67-6.35-5.57c.37-4.82-6.34-6.88-5.95-12.46.07-.96,1.36-1.31,1.96-2.42l5.08-9.34c.18-1.24-1.67-1.79-2.25-2.13l-3.45-1.97c-1-.57-1.54-1.46-1.61-2.3-.11-1.23,3.32-3.74,6.12-4.08l23.77-2.92c-4.84-1.4-9.6-.46-14.47-.77l-20.58-1.25-1.75-17.45c-.27-2.73-1.52-5.99.31-8.64,1.48-2.15,4.9-2.45,7.04-3.85,3.01-1.97-4.02-5.01-3.67-6.6,1.58-7.04-3.55-9.14-2.92-17.11l1.35-16.91c19.69,3.11,39.13,3.08,58.78.65,2.37-.29,3.87.88,5.27,2.25l.56.54-.56-.54-2.23,1.33c3.23,4.36,4.37,8.99,5.14,13.85l2.71,17.1,3.13,17.22c.82-.18.95-1.1.85-1.6-.94-4.55-.24-9.14-.6-13.84-.32-4.1-.56-7.97.06-12.11.92-6.26,1.04-12.4-.43-18.5-.18-.76-1.4-.95-1.7-.74.48-1.08.49-3.87,2.16-4.43l15.4-1.26,24.35-2.21ZM187.63,257.75c.4.93.52,1.9,1.14,2.91,2.36,3.88,4.35-8.06-7.31-13.58-7.5-3.55-15.29-1.54-21.1,4.83-13.04,14.31-17.28,39.69-4.52,53.8,5.7,6.31,14.78,8.03,22.8,5.87,13.32-3.6,20.27-20.17,13.32-16.11-.83,1.08-1.92.91-1.28-.45.23-1.19-.5-2.25-1.19-2.44-2.65-.75-3.54,8.1-13.46,10.47-5.92,1.42-11.86-1.51-14.55-6.83-4.2-8.32-4.34-17.68-1.05-26.64,3.45-9.39,10.35-17.68,18.54-13.94,4.41,2.01,4.83,8.81,5.68,8.88s1.2-.15,1.64-.32c.27-.1,1.75-2.67.35-6.47.34.07.6.1.97.03Z"/>
|
||||||
|
<path class="cls-6" d="M142.89,343.17l.29,2.88c.86.07,1.58.05,2.04.23.67.25,1.91,1.3,1.32,2.05-1.46,1.19-1.67,3.08-2.56,5.44l-38.54,2.82c-2.19.16-4,.22-5.07-1.87-.06.09-.12.09-.17.04l-31.82-30.7.34-.51,6.2,1.52c6.89,2.24,14.28,1.74,21.71,1.33,1.56-.09,1.37,2.99,1.43,3.85-.3,7.59-.1,14.62,2.15,21.47.05.41.41.65.83.61.39-.4.95-.17.83-.59l-.37-1.33.4-13.65c.01-.34.23-.76.19-.97-.05-.23.41-.49.79-.42,3.19,1.46,8.11,1.72,9.49-.15.56-.77.19-2.01.46-3.03l1.98-7.57c.19-.72-2.26-2.43-.68-2.8l6.91-1.61c2.74,4.2-.36,8.92-3.04,13.61l2.92,9.49,21.98-.14Z"/>
|
||||||
|
<path class="cls-6" d="M89.74,321.43c-1.43.12-3.07.88-4.9.94l-9.56.29c-.16,1.21.7,1.9-.37,2.41l-6.2-1.52-.57-.13-.57-.14c.06-.16.14-.32.15-.48l1.45-15.57,1.33-17.08,1.52-13.95,20.58,1.25c4.86.31,9.63-.63,14.47.77l-23.77,2.92c-2.8.34-6.23,2.85-6.12,4.08.07.84.61,1.73,1.61,2.3l3.45,1.97c.58.33,2.43.89,2.25,2.13l-5.08,9.34c-.6,1.11-1.89,1.46-1.96,2.42-.4,5.59,6.32,7.64,5.95,12.46l6.35,5.57Z"/>
|
||||||
|
<path class="cls-5" d="M185.31,203.37l.41.36,32.83,32.44c.15.15.23.32.17.52l-3.36-.08c-.18,0-.37-.17-.52.11-9.2-3.04-24.62.27-26.12-.81-2.61-1.89,1.62-13.42-2.11-26.59l-1.16-5.4-.12-.54Z"/>
|
||||||
|
<path class="cls-6" d="M219.09,263.48c-1.07.3-1.89-.5-2.51-1.36l-3.75-5.23c-1.15-1.61-1.47-4.34-3.46-5.16-1.62-.66-3.5-.91-3.74-3.16l-.98-9.54c2.56-.56,5.24-.75,8.24-.37l1.71-.29c.49-.08,1.13-1.43.74-1.77-.18,0-.37-.17-.52.11.15-.28.34-.11.52-.11l3.36.08c.06-.2-.01-.36-.17-.52.15.15.23.32.17.52,2.85,1.17,1.38,7.41,1.07,13.48l-.68,13.31Z"/>
|
||||||
|
<path class="cls-6" d="M137.02,209.09l5.09,4.92c1.03-.68.94-1.93,1.29-2.74.3-.21,1.51-.02,1.7.74,1.47,6.1,1.35,12.23.43,18.5-.61,4.14-.37,8.01-.06,12.11.37,4.7-.33,9.3.6,13.84.1.5-.03,1.42-.85,1.6l-3.13-17.22-2.71-17.1c-.77-4.87-1.91-9.49-5.14-13.85l2.23-1.33.56.54Z"/>
|
||||||
|
<path class="cls-1" d="M155.13,354.34l-6.55-5.25c-.5-.4-.58-1.25-1.2-1.09-.28.07-.6.17-.85.32.59-.75-.65-1.8-1.32-2.05-.47-.17-1.18-.15-2.04-.23l-.29-2.88c-.34-3.39.13-6.76.22-10.34l.5-20.05c.35-.26.61-.87,1.26-.95.44.36.93,1.18,1.07,2.16l3.75,25.3,4.42,9.91c.75,1.67.89,3.39,1.01,5.15Z"/>
|
||||||
|
<path class="cls-1" d="M218.76,272.12c.55,2.96-1.28,5.06-3.13,7.88-.92,1.4,1.16,2.13,1.36,3.36-.98.53-1.25,1.36-1.47-.31l-23.5-2.78c-4.93-.58-9.56-.56-14.55-2.03l21.66-2.39,16.08-1.97c1.17-.14,2.07-2.61,3.55-1.77Z"/>
|
||||||
|
<path class="cls-8" d="M144.88,311.82c-.66.08-.92.69-1.26.95-1.24.01-2.37-.28-2.74-1.23-.53-1.4-1.54-2.36-.5-3.48-.86-.08-1.19-.06-1.2.31v1.31c0,1.3.6,2.76-.52,2.99-1.43.29-2.47.29-2.91-1.32l-5.96-21.86-18.11,2.9c-2.1,7.01-4.09,13.33-6.8,19.73-1.17.31-1.78-.08-2.01-.61-.41-.95-.31-1.94-.18-3.14,1.5-.6.24-1.35-.73-.97-.56,1.24-.74,3.16-1.82,4.45-.71.85-1.64.57-2.04.13-.54-.58-.8-1.56-.57-2.34l4.78-15.62c-1.33.17-4.1,1.25-4.95-.31-1.05-1.92,1.92-2.4,3.54-2.77-1.34-1.57-3.26-.38-5.06-.44-.62-.02-.58-1.96,0-2.14l1.8-.57,7.39-1.96,12.57-37.25c.72-2.13,4.25-1.12,5.46.22.45-1.06,1.07-2.12,1.09-2.78.03-.94.98-1.12,1.53-.93.39.13,1.13.3,1.34,1.05l9.67,35.31,4.2-.07c1.29-.02,1.98,1.07,1.64,2.16-.23.73-1.57.54-1.97,1.2-.45.74,1.17,1.36.17,2.07l-1.62,1.16c-.93.67.57,1.85.39,2.35,1.24,5.52,2.93,10.37,4.83,15.35.55,2.02,1.01,4.45.58,6.18ZM122.28,263.01l-7.68,21.2,13.37-1.5-5.69-19.69Z"/>
|
||||||
|
<path class="cls-7" d="M187.63,257.75l-1.21-2.81c-.33-.78-.83-2.11-2.39-2.23l-.53-1.37c-.16-.41-.82-.21-1.64-.58-1.01-.56-1.29.55.06.58,2.16,2.07,3.86,4.01,4.73,6.38,1.4,3.8-.08,6.37-.35,6.47-.45.17-.81.39-1.64.32s-1.27-6.87-5.68-8.88c-8.19-3.73-15.09,4.55-18.54,13.94-3.29,8.97-3.16,18.32,1.05,26.64,2.69,5.33,8.63,8.25,14.55,6.83,9.92-2.37,10.81-11.22,13.46-10.47.69.19,1.42,1.25,1.19,2.44-.64,1.37.45,1.54,1.28.45,6.95-4.06,0,12.51-13.32,16.11-8.02,2.17-17.1.44-22.8-5.87-12.75-14.1-8.52-39.49,4.52-53.8,5.8-6.37,13.59-8.37,21.1-4.83,11.66,5.51,9.67,17.45,7.31,13.58-.61-1.01-.74-1.98-1.14-2.91ZM157.46,302.61l3.6,3.77c.64.67.95.15,1.82.3.52.09-.23-.61-.29-.93-.07-.41-1.13-.1-1.37-.35-1.17-1.26-1.91-3.37-3.77-2.78Z"/>
|
||||||
|
<path class="cls-1" d="M186.59,209.31c3.74,13.17-.49,24.7,2.11,26.59,1.5,1.08,16.92-2.22,26.12.81.15-.28.34-.11.52-.11.4.33-.25,1.68-.74,1.77l-1.71.29c-3-.38-5.68-.19-8.24.37-1.25.27-3.1.25-4.69.32-4.76.22-9.32,2.54-14.1,2.1-2.86-.26-3.3-5.24-3.04-7.58.92-8.32,1.46-15.9,1.54-24.08l2.22-.49Z"/>
|
||||||
|
<path class="cls-1" d="M102.87,335.37c-.38-.07-.84.19-.79.42.05.21-.18.63-.19.97l-.4,13.65.37,1.33c.12.42-.44.19-.83.59-.42.03-.78-.2-.83-.61-2.25-6.85-2.45-13.87-2.15-21.47-.06-.86.13-3.93-1.43-3.85-7.43.41-14.82.91-21.71-1.33,1.07-.51.21-1.2.37-2.41l9.56-.29c1.83-.06,3.48-.82,4.9-.94l8.08-.67c1.68-.14,4.77-1.1,5.21,1.15,1.08,5.55-1.46,10.47-.16,13.45Z"/>
|
||||||
|
<path class="cls-1" d="M187.63,257.75c-.37.07-.64.04-.97-.03-.88-2.38-2.57-4.31-4.73-6.38-1.35-.04-1.07-1.15-.06-.58.82.37,1.48.17,1.64.58l.53,1.37c1.56.12,2.06,1.45,2.39,2.23l1.21,2.81Z"/>
|
||||||
|
<polygon class="cls-5" points="122.28 263.01 127.97 282.71 114.6 284.21 122.28 263.01"/>
|
||||||
|
<path class="cls-1" d="M157.46,302.61c1.86-.59,2.59,1.52,3.77,2.78.23.25,1.3-.06,1.37.35.05.32.81,1.02.29.93-.87-.15-1.18.38-1.82-.3l-3.6-3.77Z"/>
|
||||||
|
<path class="cls-2" d="M210.46,277.3l6.02,1.81c1.38.43.78,2.5-.62,2.11,0,0-6.03-1.81-6.03-1.81-1.97-.86-4.17-1.86-6.07-2.91,1.33.16,5.03.6,6.7.81h0Z"/>
|
||||||
|
<path class="cls-4" d="M185.31,203.37l-24.35,2.21-15.4,1.26c-1.66.57-1.68,3.35-2.16,4.43-.36.81-.27,2.06-1.29,2.74l-5.09-4.92-.56-.54c-1.41-1.37-2.9-2.54-5.27-2.25-19.66,2.42-39.1,2.45-58.78-.65l-1.35,16.91c-.64,7.97,4.5,10.07,2.92,17.11-.36,1.59,6.68,4.62,3.67,6.6-2.14,1.4-5.56,1.7-7.04,3.85-1.83,2.66-.58,5.92-.31,8.64l1.75,17.45-1.52,13.95-1.33,17.08-1.45,15.57-.15.48"/>
|
||||||
|
<path class="cls-4" d="M185.32,203.34l.4.39,32.83,32.44c.15.15.23.32.17.52l-3.36-.08c-.18,0-.37-.17-.52.11-9.2-3.04-24.62.27-26.12-.81-2.61-1.89,1.62-13.42-2.11-26.59l-1.16-5.4-.11-.58Z"/>
|
||||||
|
<path class="cls-4" d="M213.54,317.63c2.77,1.35,3.4,4.57,3.09,7.75,2.44,5.7,2.42,11.51,1.87,17.57l-.91,9.96c-.14,1.55-1.69,1.51-2.77,1.41l-17.57-1.6c-4.26-.39-8.71.46-12.16-2.84l-5.53-7.04c-.25.2-.35.47-.36.58-.28,3.07-.81,5.92-2.85,8.97l-21.21,1.95-6.55-5.25c-.5-.4-.58-1.25-1.2-1.09-.28.07-.6.17-.85.32-1.46,1.19-1.67,3.08-2.56,5.44l-38.54,2.82c-2.19.16-4,.22-5.07-1.87-.62-.8-.65-1.96-.17-3.01-2.25-6.85-2.45-13.87-2.15-21.47-.06-.86.13-3.93-1.43-3.85-7.43.41-14.82.91-21.71-1.33l-6.2-1.52-.57-.13-.57-.14"/>
|
||||||
|
<polyline class="cls-3" points="100.19 354.76 68.38 324.06 67.57 323.28"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.8 KiB |
@@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #39170a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #6b1f65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #852f7e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #3d1a0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #381507;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: #9e3d96;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-6" d="M134.56,198.73c-.18.4-.63,1.06-.86,1.01l-1.53-.32,2.73,6.11c3.16,4.21,3.45,9.15,4.36,14.26l5.43,30.42-.31-26.42.69-6.77-.39-15.14c.78.13,1.2.48,1.86.21.09,1.59-.38,3.25-.01,4.91,3.95,1.89,3.31,4.73,5.82,4.74,1.17,0,3.35.51,3.8-.95.88-2.85,3.04-3.98,3.96-8.34,2.01-.62,4.59.12,5.2,2.41l2.94,4.65.47,2.59c.11.63,3.07.5,5.91,4.7.74-2.1,2.63-2.9,4.18-3.47,2.1-.76,3.81.92,5.23,2.46l1.28.36-1.2,10.28c-.16,1.36.06,4.75,1.34,5.26l12.32-1.33c1.3-.14,2.73-.73,4.27-.46-.74,2.06,2.02,3.53,2.3,4.95.63,3.15.89,5.85,3.93,7.95,1.18.81,2.14,2.65,4.4,1.82l2.14,4.34c-1.97,3.85-2.69,7.52-1.18,11.55l-7.95,3.92-9.59,1.55-24.02,4.56,31.72.18c3.86-1.21,7.59-.06,10.11,3.3-3.28,1.87-2.97,4.67-2,7.19,3.17,8.23-.69,15.5-7.22,20.39-2.27,2.33-3.26,5.96-4.66,8.69l-14.97-1.08-1.98.67c-1.17.4-1.4,6.07-1.2,10.28l-5.97.64c.01,1.31.89,2.27,1.35,3.52l-3.55,5.37c-5.53-.62-8.28,7-13.89,10.9-1.98,1.37-3.73-.25-5.2-.67-1.79-.51-3.25-.83-4.12-2.65-1.17.63-2.28.31-3.09,0-.93-.36-1.45-1.42-1.58-2.63l-1.24-12.07-.54-21.41-3.37,26.93-2.32,11.58c-1.35-.6-2.56-.79-3.93-.64-2.87.32-5.45-.38-8.31-.41-1.55-.02-1.88-1.53-3.11-3.07l2.02-2.29c-1.1-2.21-3.48-2.22-5.28-3.2-.75-.41-1.27-1.25-2.14-1.2-2.2.13.67,6.44-2.89,6.37l-6.18-.11c-.97-.02-1.52-.97-1.46-1.38l.36-2.39c-2.16-3.31-6.64-4.97-10.34-6.05l-4.61-1.35,9.71-11.1c1.03-1.17,2.56-2.27,2.2-3.91l-10.97,8.35c-4.57-1.25-8.28-3.55-8.21-8.02-.63-.81-1.9-1.86-1.64-2.9.76-2.95,3.93-2.39,4-6.01.03-1.5-1.05-2.01-2.21-3.07-1.5-1.37-2.59-3.79-4.86-4.28-1.89-.41-6.44-4.24-7.02-6.09-1.01-3.25,2.85-5.63.92-8.2l8.46-2.25,18.21-3.46.55,22.34-4.07-1.09c-.62-.17-1.15.71-1.26,1.11-.71,2.66,6.57,4.49,6.46,4.96-.06.26-.4.99-.55.77-.26-.37-.68-.81-1.29-.89l-2.57-.33c-.63-.08-1.82,2.04-.78,2.4,13.51,4.72,30.52,4.52,40.19-6.04,4.26-4.65,7-10.96,5.44-17.11-1.88-7.43-8.07-12.69-15.99-13.65,4.61-3.97,8.07-9.52,6.06-15.48s-7.82-9.61-13.78-10.33c-13.8-1.66-26.43,7.86-23.9,11.48l1.82.27c.83.77.9,1.54,1.58,1.28l2.21-.86-.14,17.59-10.69.46-11.02-.53c-3.1-.15-5.69-2.07-8.52-1.76l-2.44-19.92c-1.31-2.93-.76-6.46,2.46-7.96,3.42-1.59,4.59-3.37,5.99-2.92.03-2.33-2.37-3.38-4.36-4.69l-.32-6.53c-5.46-9.7-.92-17.59-1.51-28.43l16.23,2.03,23.56-.02,16.48-1.23c2.97-.22,5.24-1.39,7.61.8ZM185.54,293.84c2.42-3.82,5.39-7.88,3.02-9.23s-3.38,6.71-11.7,9.93c-4.18,1.62-8.99.92-12.68-1.81-3.38-2.5-5.13-6.55-6.15-11.09-3.58-15.97,6.89-36.59,18.87-32.85,6.82,2.13,4.36,12.37,8.6,8.12.92-1.52.02-3.6.33-5.64l2.51,3.38c1.54-.91,1.02-2.87.63-4.33-1.61-6.12-7.34-9.87-13.12-11.04-6.3-1.28-11.96,1.46-16.42,6.15-6.23,6.56-9.49,15.21-11.29,23.96-2.95,14.33,3.3,32.61,19.18,34.63,11.19,1.43,22.11-4.68,26.13-15.27.19-.49.04-1.09.04-1.41,0-.73-2.81-.21-2.68-.3-1.54,1.13-1.96,6.72-5.28,6.8Z"/>
|
||||||
|
<path class="cls-3" d="M74.46,278.72c1.93,2.57-1.93,4.94-.92,8.2.57,1.85,5.13,5.69,7.02,6.09,2.27.49,3.37,2.91,4.86,4.28,1.15,1.06,2.24,1.57,2.21,3.07-.07,3.62-3.24,3.06-4,6.01-.26,1.03,1.01,2.08,1.64,2.9-.07,4.47,3.65,6.77,8.21,8.02l10.97-8.35c.36,1.64-1.18,2.73-2.2,3.91l-9.71,11.1,4.61,1.35c3.7,1.08,8.18,2.75,10.34,6.05l-.36,2.39c-.06.4.49,1.36,1.46,1.38l6.18.11c3.56.07.69-6.24,2.89-6.37.86-.05,1.39.79,2.14,1.2,1.8.99,4.18.99,5.28,3.2l-2.02,2.29c1.23,1.55,1.56,3.06,3.11,3.07,2.87.03,5.44.73,8.31.41,1.37-.15,2.57.04,3.93.64l2.32-11.58,3.37-26.93.54,21.41,1.24,12.07c.12,1.21.65,2.27,1.58,2.63.81.32,1.92.64,3.09,0,.86,1.82,2.33,2.14,4.12,2.65,1.47.42,3.22,2.04,5.2.67,5.6-3.9,8.36-11.52,13.89-10.9l3.55-5.37c-.47-1.25-1.34-2.21-1.35-3.52l5.97-.64.85,18.06-.17,3.08c.67-.03,1.46-.09,2.22.11l-1.57,4.51-.4,1.16-.74-.15-9.48-.44-13.17-.04c-5.51-.02-10.81.2-15.91-2.37-4.17,3.3-8.82,3.85-13.75,4.04l-11.57.44c-4.22.16-8-1.47-12.1-2.44l-9.23-2.18-2.37-2.53-1-1.14c-.19-.22-.42.16-1.49,0l-2.88,3.29c-1.64,3.38-4.27,3.48-7.57,2.93l-8.76-1.47c-1.06-.18-1.68-1.56-2.63-2.24l-.25-10.04c-.09-3.55-2.33-6.92-2.12-10.04l.33-4.87,1.13-9.81c.44-3.77.39-7.85,0-11.53l-.45-4.37c-.88-2.66-.01-5.19,1.15-7.52l3.1-6.18c.48,0,.8.42,1.38.27Z"/>
|
||||||
|
<path class="cls-3" d="M219.05,227.87l.79.78-.21.25.6.14-.13.88-1.61,30.29-6.75,7.18,6.67,3.15,1.66,35.78c.09,1.99,0,4.05-1.49,5-.19.12-.4.28-.59.29l.1.11-.1-.11-3.17.11c-.29-.45-.37-1.65-1.08-1.62l-5.37.21c-2.72.1-5.69.2-8.32.01,1.39-2.73,2.38-6.36,4.66-8.69,6.53-4.89,10.39-12.17,7.22-20.39-.97-2.52-1.28-5.32,2-7.19-2.53-3.36-6.25-4.51-10.11-3.3l-31.72-.18,24.02-4.56,9.59-1.55,7.95-3.92c-1.51-4.02-.8-7.7,1.18-11.55l-2.14-4.34c-2.26.84-3.21-1-4.4-1.82-3.04-2.1-3.3-4.8-3.93-7.95-.28-1.42-3.04-2.89-2.3-4.95l3.85-.57,8.82.23c.74-.32,1.1.42,1.09-.02-.02-.58.56-1.21.18-1.42l2.43.46.62-.74Z"/>
|
||||||
|
<path class="cls-3" d="M188.5,197.92c-.14-.14-.26-.35-.42-.36.16.02.28.22.42.36l-.8,2.07.09,1.09-1.96.21c.42,5.17.06,10.03-.5,14.87l-1.28-.36c-1.42-1.53-3.13-3.22-5.23-2.46-1.55.57-3.44,1.37-4.18,3.47-2.84-4.2-5.79-4.07-5.91-4.7l-.47-2.59-2.94-4.65c-.61-2.29-3.19-3.03-5.2-2.41-.92,4.36-3.08,5.48-3.96,8.34-.45,1.46-2.64.96-3.8.95-2.51,0-1.87-2.85-5.82-4.74-.37-1.66.1-3.32.01-4.91-.66.27-1.08-.08-1.86-.21l.39,15.14-.69,6.77.31,26.42-5.43-30.42c-.91-5.11-1.2-10.05-4.36-14.26l-2.73-6.11,1.53.32c.22.05.68-.6.86-1.01,2.07,1.91,4.3,4.1,6.23,6.93l3.69-7.11,37.01-2.68c2.54-1.25,5.05-.41,6.58,1.69.16.02.28.22.42.36Z"/>
|
||||||
|
<path class="cls-6" d="M217.99,311.6l-.39.42-33.59,33.88-.77.03,1.57-4.51c2.75-7.94,3.11-16.57,1.63-25.15-.21-1.2,1.02-2.43,2.16-2.43l11.01.06c5.33.03,10.37-.9,15.23-2.19l3.17-.11Z"/>
|
||||||
|
<path class="cls-3" d="M219.05,227.87l-.62.74-2.43-.46c-8.06-2.53-16.09-3.31-24.73-2.08-1.96.28-2.28-4.19-1.56-5.38.71-6.92-.38-13.29-1.92-19.6l-.09-1.09.8-2.07c-.14-.14-.26-.35-.42-.36.16.02.28.22.42.36l30.55,29.94Z"/>
|
||||||
|
<path class="cls-2" d="M101.08,269.43l.04,3.58-18.21,3.46-8.46,2.25c-.58.15-.9-.27-1.38-.27l2.44-3.67c-.82-2.73-4.33-4.52-4.66-7.18,2.83-.32,5.42,1.61,8.52,1.76l11.02.53,10.69-.46Z"/>
|
||||||
|
<path class="cls-1" d="M101.08,269.43l.14-17.59-2.21.86c-.67.26-.75-.52-1.58-1.28l-1.82-.27c-2.52-3.62,10.1-13.15,23.9-11.48,5.96.72,11.76,4.34,13.78,10.33s-1.45,11.51-6.06,15.48c7.92.96,14.11,6.22,15.99,13.65,1.56,6.15-1.18,12.46-5.44,17.11-9.67,10.56-26.68,10.76-40.19,6.04-1.04-.36.15-2.48.78-2.4l2.57.33c.61.08,1.03.52,1.29.89.15.22.49-.51.55-.77.11-.47-7.17-2.29-6.46-4.96.11-.41.64-1.28,1.26-1.11l4.07,1.09-.55-22.34-.04-3.58ZM124.8,255.68c1.58-3.1.42-5.63-2.19-7.08-3.93-2.19-8.3-2.63-13.25-1.34v16.49c0,.62.35,1.06.86,1.37.26.15.45-.22,1.12-.47,5.49-1.09,10.87-3.89,13.45-8.97ZM106.69,248.96l-1.41-.03.17,8.81c.23-.03.44-.47.56-.3l.68-8.48ZM129.95,257.42c.65-1.58.8-4.23,1.16-5.86-1.76-.16-.51-1.16-.52-1.51,0-.22-.65-.01-1.42-.09,1.09,3.53.8,7.12-1.99,10.11,1.03.81,1.77-.21,1.99-.75l.77-1.9ZM133.96,284.81c.89-6.65-3.22-11.63-9.46-12.87-5.07-1.01-9.78.28-14.97,1.72l.7,23.53c5.81,1,11.07-.12,16.57-2.3,3.82-1.51,6.61-6.02,7.16-10.08ZM105.85,278.54c-.96,2.12-1.03,4.41.28,6.27.22-1.99.88-3.84-.28-6.27ZM138.89,279.18h-1.19s.2,6.6.2,6.6c.2-.03.41-.45.51-.3.97-1.95.1-4,.47-6.3Z"/>
|
||||||
|
<path class="cls-4" d="M185.54,293.84c3.32-.08,3.73-5.67,5.28-6.8-.13.1,2.68-.43,2.68.3,0,.32.14.92-.04,1.41-4.02,10.58-14.94,16.69-26.13,15.27-15.88-2.03-22.13-20.3-19.18-34.63,1.8-8.74,5.06-17.4,11.29-23.96,4.45-4.69,10.12-7.43,16.42-6.15,5.78,1.17,11.5,4.93,13.12,11.04.38,1.46.9,3.42-.63,4.33l-2.51-3.38c-.3,2.04.6,4.12-.33,5.64-4.24,4.25-1.78-5.98-8.6-8.12-11.98-3.75-22.45,16.87-18.87,32.85,1.02,4.54,2.77,8.58,6.15,11.09,3.68,2.73,8.49,3.43,12.68,1.81,8.32-3.22,9.32-11.28,11.7-9.93s-.59,5.41-3.02,9.23Z"/>
|
||||||
|
<path class="cls-2" d="M187.78,201.08c1.54,6.31,2.64,12.68,1.92,19.6-.72,1.2-.4,5.66,1.56,5.38,8.64-1.23,16.67-.45,24.73,2.08.38.2-.2.83-.18,1.42.02.44-.35-.31-1.09.02l-8.82-.23-3.85.57c-1.54-.27-2.98.32-4.27.46l-12.32,1.33c-1.29-.51-1.5-3.9-1.34-5.26l1.2-10.28c.57-4.84.93-9.7.5-14.87l1.96-.21Z"/>
|
||||||
|
<path class="cls-2" d="M200.05,310.31c2.64.19,5.6.09,8.32-.01l5.37-.21c.71-.03.79,1.17,1.08,1.62-4.86,1.29-9.9,2.22-15.23,2.19l-11.01-.06c-1.13,0-2.36,1.22-2.16,2.43,1.48,8.57,1.13,17.21-1.63,25.15-.76-.2-1.55-.15-2.22-.11l.17-3.08-.85-18.06c-.2-4.21.03-9.88,1.2-10.28l1.98-.67,14.97,1.08Z"/>
|
||||||
|
<path class="cls-6" d="M133.96,284.81c-.55,4.07-3.34,8.57-7.16,10.08-5.5,2.17-10.76,3.29-16.57,2.3l-.7-23.53c5.2-1.44,9.9-2.72,14.97-1.72,6.24,1.24,10.35,6.22,9.46,12.87Z"/>
|
||||||
|
<path class="cls-6" d="M124.8,255.68c-2.58,5.08-7.97,7.88-13.45,8.97-.67.25-.86.62-1.12.47-.51-.3-.87-.74-.87-1.37v-16.49c4.96-1.3,9.32-.85,13.25,1.34,2.61,1.45,3.76,3.98,2.19,7.08Z"/>
|
||||||
|
<path class="cls-2" d="M129.95,257.42l-.77,1.9c-.22.54-.96,1.56-1.99.75,2.79-3,3.08-6.58,1.99-10.11.77.08,1.42-.13,1.42.09,0,.36-1.24,1.36.52,1.51-.36,1.63-.51,4.27-1.16,5.86Z"/>
|
||||||
|
<path class="cls-6" d="M106.69,248.96l-.68,8.48c-.13-.17-.33.27-.56.3l-.17-8.81,1.41.03Z"/>
|
||||||
|
<path class="cls-2" d="M138.89,279.18c-.37,2.3.5,4.35-.47,6.3-.1-.15-.32.27-.51.3l-.2-6.6h1.19Z"/>
|
||||||
|
<path class="cls-2" d="M105.85,278.54c1.16,2.43.51,4.28.28,6.27-1.31-1.86-1.24-4.15-.28-6.27Z"/>
|
||||||
|
<path class="cls-5" d="M220.24,229.03l-.6-.14-1.21-.28-2.43-.46c-8.06-2.53-16.09-3.31-24.73-2.08-1.96.28-2.28-4.19-1.56-5.38.71-6.92-.38-13.29-1.92-19.6l-.09-1.09"/>
|
||||||
|
<path class="cls-5" d="M76.87,236.81c.03-2.33-2.37-3.38-4.36-4.69l-.32-6.53c-5.46-9.7-.92-17.59-1.51-28.43l16.23,2.03,23.56-.02,16.48-1.23c2.97-.22,5.24-1.39,7.61.8,2.07,1.91,4.3,4.1,6.23,6.93l3.69-7.11,37.01-2.68c2.54-1.25,5.05-.41,6.58,1.69.16.02.28.22.42.36l30.55,29.94.79.78.4.39"/>
|
||||||
|
<path class="cls-5" d="M220.24,229.03l-.13.88-1.61,30.29-6.75,7.18,6.67,3.15,1.66,35.78c.09,1.99,0,4.05-1.49,5l-.49.39-.5.31-33.59,33.88-1.17,1.19"/>
|
||||||
|
<path class="cls-5" d="M182.84,347.08l-.74-.15-9.48-.44-13.17-.04c-5.51-.02-10.81.2-15.91-2.37-4.17,3.3-8.82,3.85-13.75,4.04l-11.57.44c-4.22.16-8-1.47-12.1-2.44l-9.23-2.18-2.37-2.53"/>
|
||||||
|
<path class="cls-5" d="M182.84,347.08l.4-1.16,1.57-4.51c2.75-7.94,3.11-16.57,1.63-25.15-.21-1.2,1.02-2.43,2.16-2.43l11.01.06c5.33.03,10.37-.9,15.23-2.19l3.17-.11"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #006d30;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #00873e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #381507;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2.75px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #3a160a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: #3d180d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: #00a04b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-6" d="M153.66,195.96l-.46-.48-2.25.71,3.07,18.95.16,18.83c.02,1.82-1.11,3.58.04,5.52.86-1.54,1.21-3.1,1.51-4.81l4.63-26.09c.51-2.89.63-6.32-1.68-7.91,3.44-.33,1.92-4.68,4.63-6.02,1.06-.53,2.58-.34,3.85-.57l.57-.11.09,1.97c-1.23.02-2.43-.35-3.03.56,1.72-.21,4.69,1.64,3.78,3.07-1.78,2.8-2.55,5.21-1.44,8.53.2.59-.73,1.95-.05,2.92.42.6,1.37.66,2.45,1.35l.89,1.5c.22.37,1.97.27,1.95-.45-.06-2.39-4.42-4.98-2.14-5.98.89-.39,1.59-.33,2.08.47l3.78,6.21,3.2-1.66,6.99.65c-.45,5.85-2.86,9.72.48,15.56l8.02-1.38c4.46-.77,3.87,5.08,6.83,6.44-.37,2.19.28,4.05,1.64,6.45-6.86,3.73-8.55.88-9.78,1.54-2.84,1.52-2.36,6.78,0,8.07,2.28-5.08,5.7-2.99,8.83-5.88l3.1,3.95c.13.5-.52.62-.77.88-.49.54-1.76-.53-2.23-.28-1.18.63-.37,1.39-.08,1.77.39.5,2.4.95,4.35,1.17.61,1.74-1.58,2.73-2.4,3.52.4,2.18,3.63,1.16,5.22,1.12,1.87-.05,3.1,2.33,5.08,1.26l-.3,8.25c-7.48,2.34-14.03,3.49-21.14,4.16l-23.27,2.2c.09.33.06.66.03.83.7.61,1.85-.06,2.86-.16l30.5,2.09c2.57.18,5.12.05,7.54,1.41-6.47,3.24-11.29,0-12.25.87-.59.54-1.48,2.27-.13,2.72.91.3,1.87.35,2.3.69,1.2.95-1.2,5.83,1.12,6.74.78.3,2.4-.64,2.93.56.31.71.23,2.04-.94,2.69l-1.39,1.83c-1.39.83-1.28,1.3-1.07,2.69.24,1.57,1.51,1.36,3.53.89v3.28s4.12.29,4.12.29l.1,2.95c-4.9.84-9.59,2.46-12.77,6.65l-8.81-1.19c-7.44-1-2.19,11.29-2.46,19.21l-4.89-.16c-2.63-.09-6.35,0-7.58-2.4,1.86-2.75-1.3-5.62-.12-9.66-1.36-.25-2.17-1.48-3.52-1.43-2.58,1.83-5.92,2.68-6.55,5.79-.14.7,1.35,1.6.78,2.39-.88,1.22-5.4.29-5.27,3.28.14,3.32,3.47,1.65,4.91,3.68-1.55,1.93-3.69,4.09-5.51,5.41-.67.04-2.1-.08-2.75-.12l-7.79-.48-1.91-10.68-.87-20.54c-.02-.38.98-.89.38-1.07-.21-.06-.37-.3-.58-.52-.15-.16-.64.62-.67.95l-1.71,16.74c-.72,7.06-2.02,13.71-4.34,20.67-4.06.6-8.21-.87-12.16-2.67-.9-.41-2.41-.75-2.22-2.2.46-.15,1.4-.34,1.49-.82.06-.36.32-1.32-.43-1.33l-9.76-.09c-.43,0-.7-1.15-.36-1.47.61-.58,1.76-.61,2.03-1.46.31-.96.25-1.75.13-3.16l-14.56.05c-3.39.01-6.59.16-10.04-.64l12.59-14.11c-.78-.51-1.89.32-2.63,1.01-4.11,3.83-8.08,7.52-13.71,8.92.38-1.11,1.48-1.61,2.04-2.49l-1.39-4.29c-2.64.06-2.8-2.85-3.9-3.74-2.66-2.14-5.75-3.87-5.86-7.47-.13-4.69,5.58-4.68,4.87-7.46-1.89-.09-2.79-.22-3.53-1.67-2.76-5.4-5.55-10.74-6.13-16.95l.26-3.84c1.78-1.26,3.89-1.22,6.04-1.61l19.34-3.5c-7.89-1.44-15.42-1.63-22.88-4.1l-3.59-.27c-1.87-2.75-1.64-5.92-2.07-9l-1.2-8.62-.43-6.7c-.53-8.33-.99-6.87,3.53-10.65l3.35-2.87.41.38-.41-.38-6.25-5.77,1.99-24.45,29.01,2.92c.3,2.58-.26,4.92.22,7,.52-.09,1.76-.17,2.02.58.15.44.4.77.33,1.31l3.48,3.71c1.73,1.85,4.57,3.61,5.86,5.97,1.68,3.06,3.62,8.54,4.74,8.09.28-.11.94-.29,1.08-1.02.16-.85-.78-2.14-.85-3.37l-.49-9.68c-1.06-2.47-3.15-4.98-2.29-7.94l7.42-2.99.74-.79c.25-.26.12-.53-.1-.59-.25-.07-.86-.46-1.49.09l-.35-1.99,28.3-2.23c1.58-.12,3.1-.91,3.9,1l.46.48ZM186.99,284.59c.05-.8-.21-2.93-.89-3.46-1.03-.8-2.09-.08-2.67.96-2.92,5.19-8.02,9.43-14.12,9.6-10.92.3-16.23-12.43-14.77-24.11,1.5-12,9.42-26.8,19.63-23.04,6.4,2.36,4.16,10.81,8.14,8.64,1.03-.56,1.08-3.75.59-6.65,1.56,1.07,1.13,2.55,1.92,3.73.35.51,1.07.12,1.41-.26,1.54-1.69-1.42-11.67-11.3-14.47-18.44-5.21-31.37,21.26-30.31,39.87.46,8.02,4.55,18.71,12.73,22.74,12.45,6.13,27.46.08,33.05-12.33.29-.63.45-1.54.17-1.99s-.96-1.1-1.65-.81c-.59.25-1.21.83-1.92,1.58ZM101.07,245.27c-.97-.28-1.36,2.51-.27,3.04.84.41,1.82.3,3.43.02l-.02,15.84.52,28.42c.02,1.26-3.05.8-3.99,2.17-.37.54-.43,1.53.14,1.9.48.31,1.1.34,2.1.38l1.74.07c.21,0-.06,1.21-.78.9-1.53-.66-2.37,1.04-2.06,2.1.54,1.85,3.63,1.46,6.98.54.54.72.85,2.18,1.64,2.22.74.04,1.45-.09,1.73-.57.43-.71.32-1.49.26-2.52l13.46-1.99,15.07-1.37c.5-.05,1.21-.93,1.18-1.32-.02-.33-.33-.94-.83-.93l-6.64.11v-.92s7.21-.58,7.21-.58c.67-.05,1.07-1,1.09-1.41s-.74-1.12-1.09-1.32l-2.18-1.26c-7.01-.15-13.57.87-20.29,1.77l-6.33.85-.28-18.72c1.4-.01,2.1-.11,2.8-.21l14.99-2.18c1.84-.27,2.77-2.22,2.67-4.08-1.51-.62-2.4-.02-3.68-.25.35-1.33,1.22-1.8.93-2.28-.46-.75-.97-1.35-1.81-1.22l-16.69,2.55v-17.6c8.76-1.96,17.15-2.99,25.82-3.25.13,0,3.38-2.78,2.38-4.14-.56-.77-2.05-.63-3.18-.4-.1-1.28.99-1.47.68-1.91-.6-.86-1.04-1.38-1.97-1.52-8.39.05-16.34,1.7-24.78,2.69-.47-.45-.8-1.64-1.29-1.61-.68.04-.97.86-1.59,1.72-.59-.64-1.13-1.26-1.75-1.19-.85.11-1.69,0-1.57,1.15.13,1.23-2,1.2-2.96,1.52l-2.84.93c-1.36.44-1.02,1.83-.51,2.86l5.73-.22-1.66.49c-.18.57-.19,1.1-1.51.72Z"/>
|
||||||
|
<path class="cls-2" d="M189.09,192.96c.05.24.06.17.06-.01l.12.59,1.48,13.12.05,15.42c9.75-1.12,18.49.18,27.17,2.92l.66-.85,1.15,1.22.02.65-.12,6.48c-.06,3.28-.1,6.15-.34,9.4l-1.49,20.28c-.14,1.92.53,4.27-.65,6.12l-5.41,3.97,6.02,6.36,1.49,21.56c.37,5.41,2.59,8.94-3.33,12.4.34.27.29,1.3.03,1.53s-.16.34-.97.66c-.37,1.66-2.22,1.95-3.2,2.95l-20.55,20.92c-1.09,1.11-1.62,2.3-2.99,2.91-1.44.64-2.89,1.38-4.34,2.88l-11.83-.23-21.18-.57c-2.81-.08-5.69-.28-7.16-3.49l-2.05-.16c-3.58,7.25-10.93,5.75-18.03,6.21-3.56.23-7.24,1.2-10.75.28l-13.96-3.64c-3.32-.87-4.74-4.5-6.28-4.44-1.22.05-1.49,1.13-2.18,1.92l-3.06,3.53c-1.81,2.08-4.75,1.11-7.01.74l-7.91-1.31c-.88-.14-1.36-1.64-2.36-2.17.88-6.23-.67-11.82-2.56-17.57-.32-.99.84-5.27,1.01-8.28l.29-4.88.53-6.71.52-27.67,1.82-3.79c.68.02,1.42.1,2.03.75l-.26,3.84c.58,6.2,3.37,11.55,6.13,16.95.74,1.45,1.64,1.58,3.53,1.67.71,2.78-5.01,2.77-4.87,7.46.1,3.59,3.19,5.33,5.86,7.47,1.1.88,1.26,3.8,3.9,3.74l1.39,4.29c-.55.88-1.66,1.38-2.04,2.49,5.64-1.4,9.6-5.08,13.71-8.92.74-.69,1.84-1.52,2.63-1.01l-12.59,14.11c3.44.8,6.65.65,10.04.64l14.56-.05c.11,1.41.17,2.2-.13,3.16-.27.85-1.42.88-2.03,1.46-.33.32-.07,1.46.36,1.47l9.76.09c.76,0,.5.97.43,1.33-.08.47-1.03.66-1.49.82-.2,1.44,1.31,1.79,2.22,2.2,3.95,1.79,8.1,3.27,12.16,2.67,2.32-6.96,3.62-13.61,4.34-20.67l1.71-16.74c.03-.33.52-1.11.67-.95.21.23.36.46.58.52.6.17-.4.69-.38,1.07l.87,20.54,1.91,10.68,7.79.48c.64.04,2.08.16,2.75.12,1.82-1.32,3.96-3.47,5.51-5.41-1.45-2.03-4.77-.36-4.91-3.68-.13-2.98,4.38-2.06,5.27-3.28.57-.79-.92-1.69-.78-2.39.63-3.11,3.98-3.96,6.55-5.79,1.36-.05,2.16,1.19,3.52,1.43-1.18,4.04,1.98,6.9.12,9.66,1.23,2.39,4.95,2.31,7.58,2.4l4.89.16c.28-7.91-4.98-20.21,2.46-19.21l8.81,1.19c3.18-4.19,7.87-5.81,12.77-6.65l-.1-2.95-4.12-.29v-3.28c-2.02.46-3.28.68-3.52-.89-.21-1.38-.32-1.86,1.07-2.69l1.39-1.83c1.17-.65,1.25-1.97.94-2.69-.53-1.2-2.15-.26-2.93-.56-2.32-.9.09-5.78-1.12-6.74-.43-.34-1.39-.39-2.3-.69-1.35-.45-.46-2.18.13-2.72.95-.87,5.78,2.37,12.25-.87-2.42-1.36-4.97-1.23-7.54-1.41l-30.5-2.09c-1,.11-2.15.77-2.86.16.03-.17.07-.51-.03-.83l23.27-2.2c7.11-.67,13.66-1.83,21.14-4.16l.3-8.25c-1.98,1.07-3.21-1.31-5.08-1.26-1.6.04-4.83,1.06-5.22-1.12.82-.79,3.01-1.78,2.4-3.52-1.94-.22-3.96-.67-4.35-1.17-.3-.39-1.1-1.14.08-1.77.47-.25,1.75.82,2.23.28.24-.27.9-.39.77-.88l-3.1-3.95c-3.13,2.9-6.55.8-8.83,5.88-2.36-1.3-2.84-6.55,0-8.07,1.24-.66,2.93,2.18,9.78-1.54-1.36-2.4-2.01-4.25-1.64-6.45-2.96-1.36-2.37-7.21-6.83-6.44l-8.02,1.38c-3.34-5.83-.94-9.71-.48-15.56l-6.99-.65-3.2,1.66-3.78-6.21c-.48-.79-1.18-.86-2.08-.47-2.29,1,2.08,3.59,2.14,5.98.02.72-1.73.82-1.95.45l-.89-1.5c-1.07-.69-2.02-.75-2.45-1.35-.68-.97.25-2.32.05-2.92-1.11-3.33-.34-5.73,1.44-8.53.91-1.43-2.05-3.28-3.78-3.07.6-.91,1.8-.54,3.03-.56l-.09-1.97-.57.11.57-.11,13.46-1.04c2.81-.22,5.44-.4,7.96,0,0,.25,0,.18-.06.01Z"/>
|
||||||
|
<path class="cls-6" d="M189.09,192.96c.13-.05.29.17.44.4l29.1,30.8-.66.85c-8.68-2.74-17.43-4.04-27.17-2.92l-.05-15.42-1.48-13.12-.12-.59c0,.18-.01.25-.06.01Z"/>
|
||||||
|
<path class="cls-2" d="M121,196.71l.35,1.99c.63-.55,1.25-.16,1.49-.09.22.06.34.33.1.59l-.74.79-7.42,2.99c-.86,2.97,1.23,5.48,2.29,7.94l.49,9.68c.06,1.23,1,2.52.85,3.37-.14.73-.8.91-1.08,1.02-1.11.45-3.05-5.03-4.74-8.09-1.29-2.35-4.13-4.12-5.86-5.97l-3.48-3.71c.07-.54-.17-.88-.33-1.31-.26-.75-1.5-.67-2.02-.58-.48-2.08.07-4.42-.22-7,3.74.38,6.19,6.49,8.76,5.54,2.12-.78.47-6.55,7.47-6.94l4.09-.23Z"/>
|
||||||
|
<path class="cls-1" d="M153.66,195.96c1.59,1.69,3.16,3.44,5.02,4.72,2.32,1.59,2.2,5.02,1.68,7.91l-4.63,26.09c-.3,1.71-.65,3.27-1.51,4.81-1.16-1.94-.03-3.7-.04-5.52l-.16-18.83-3.07-18.95,2.25-.71.46.48Z"/>
|
||||||
|
<path class="cls-1" d="M73.83,272.96c-.61-.65-1.35-.72-2.03-.75l3.58-4.84-2.64-3.89,3.59.27c7.46,2.46,14.99,2.66,22.88,4.1l-19.34,3.5c-2.15.39-4.26.34-6.04,1.61Z"/>
|
||||||
|
<path class="cls-4" d="M101.07,245.27c1.32.37,1.33-.16,1.51-.72l1.66-.49-5.73.22c-.51-1.03-.85-2.41.51-2.86l2.84-.93c.97-.32,3.09-.29,2.96-1.52-.12-1.15.72-1.05,1.57-1.15.61-.08,1.16.55,1.75,1.19.62-.86.91-1.67,1.59-1.72.49-.03.82,1.16,1.29,1.61,8.43-1,16.39-2.64,24.78-2.69.93.14,1.37.67,1.97,1.52.31.44-.79.63-.68,1.91,1.13-.23,2.61-.37,3.18.4,1,1.35-2.25,4.13-2.38,4.14-8.67.26-17.05,1.29-25.81,3.25v17.6s16.68-2.55,16.68-2.55c.84-.13,1.36.47,1.81,1.22.29.47-.58.95-.93,2.28,1.28.22,2.17-.38,3.68.25.1,1.86-.83,3.81-2.67,4.08l-14.99,2.18c-.7.1-1.4.2-2.8.21l.28,18.72,6.33-.85c6.72-.9,13.28-1.91,20.29-1.77l2.18,1.26c.35.2,1.11.91,1.09,1.32s-.42,1.36-1.09,1.41l-7.21.58v.92s6.65-.11,6.65-.11c.5,0,.81.59.83.93.02.4-.69,1.28-1.18,1.32l-15.07,1.37-13.46,1.99c.07,1.03.17,1.8-.26,2.52-.29.48-.99.61-1.73.57-.79-.04-1.1-1.5-1.64-2.22-3.36.92-6.44,1.31-6.98-.54-.31-1.06.53-2.77,2.06-2.1.72.31,1-.89.78-.9l-1.74-.07c-.99-.04-1.62-.08-2.1-.38-.57-.37-.51-1.36-.14-1.9.94-1.37,4.01-.91,3.99-2.17l-.52-28.42.02-15.84c-1.61.28-2.59.39-3.43-.02-1.08-.53-.7-3.32.27-3.04Z"/>
|
||||||
|
<path class="cls-5" d="M186.99,284.59c.71-.75,1.33-1.33,1.92-1.58.69-.29,1.36.35,1.65.81s.12,1.36-.17,1.99c-5.59,12.41-20.6,18.47-33.05,12.33-8.18-4.03-12.28-14.72-12.73-22.74-1.06-18.61,11.87-45.08,30.31-39.87,9.88,2.79,12.84,12.78,11.3,14.47-.34.38-1.07.77-1.41.26-.8-1.18-.36-2.66-1.92-3.73.49,2.89.44,6.09-.59,6.65-3.98,2.17-1.74-6.28-8.14-8.64-10.22-3.76-18.14,11.04-19.63,23.04-1.45,11.68,3.85,24.41,14.77,24.11,6.1-.17,11.2-4.41,14.12-9.6.59-1.04,1.65-1.76,2.67-.96.68.53.94,2.66.89,3.46-.24,0-.82-.26-1.15.32l-2.56,4.45.51.54c1.45-1.23,3.14-4.22,3.13-4.06l.07-1.24Z"/>
|
||||||
|
<path class="cls-1" d="M186.99,284.59l-.07,1.24c0-.16-1.68,2.83-3.13,4.06l-.51-.54,2.56-4.45c.33-.57.91-.32,1.15-.32Z"/>
|
||||||
|
<path class="cls-3" d="M219.78,225.37l-1.81-.37c-8.68-2.74-17.43-4.04-27.17-2.92l-.05-15.42-1.48-13.12-.19-.58"/>
|
||||||
|
<path class="cls-3" d="M219.78,225.37l.02.65-.12,6.48c-.06,3.28-.1,6.15-.34,9.4l-1.49,20.28c-.14,1.92.53,4.27-.65,6.12l-5.41,3.97,6.02,6.36,1.49,21.56c.37,5.41,2.59,8.94-3.33,12.4-7.99.64-14.33,1.69-23.27.55-5.4-.69-.74,10.29-4.65,22.7-.68,2.15-.83,4.01.26,5.73"/>
|
||||||
|
<path class="cls-3" d="M189.15,192.95c-2.52-.41-5.15-.22-7.96,0l-13.46,1.04-.57.11c-1.27.24-2.79.05-3.85.57-2.71,1.35-1.19,5.7-4.63,6.02-1.86-1.28-3.42-3.03-5.02-4.72l-.46-.48c-.8-1.91-2.32-1.12-3.9-1l-28.3,2.23-4.09.23c-7.01.39-5.35,6.16-7.47,6.94-2.57.94-5.02-5.17-8.76-5.54l-29.01-2.92-1.99,24.45,6.25,5.77.41.38.41.37"/>
|
||||||
|
<polyline class="cls-3" points="189.15 192.95 189.53 193.36 218.63 224.15 219.78 225.37"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,56 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #39170a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #381507;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2.75px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #3d180b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #a88a21;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: #d3ac2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: #ffcf34;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-6" d="M179.77,205.02l.77,4.04c.04.21.1.45.08.59.02-.14-.04-.38-.08-.59-.85,1.19-1.1-.58-2.67-.25l.79,8.42-.89,10.4c-.41,4.82-1.24,9.97,1.41,14.45,1.05,1.78,9.63-.31,17.16-.75,1.02,1.74,4.84.68,6.63,4.83.88,2.04,2.91,3.45,3.86,5.38,1.4,2.81,2.25,5.73,4.84,7.45,1.02-.82,1.88-.07,2.66-.28l.07,6.27c.03,2.58.2,4.71-.47,6.93-.75.09-2.1-.23-2.94.31-2.87,1.83-6.08,2.33-9.57,2.78l-6.5.84-16.99.61c-3.66.13-7.28-.69-11.12,1.38l16.47,2.61,22.92,3.07,7.37.46c.9,1.94.62,4.11.83,6.29l1.64,17c.53,5.45-8.08,6.66-5.27,8.93,2.01,1.62,5.55,12.81,5.16,16.41l-2.13,19.73c-8.18,1.27-16.4,2.09-24.72,1.88l-6.37-8.74c-.46-.63-1.12-1.06-1.64-1.08-.67-.03-1.56.51-2.14,1.26l-5.23,6.74-16.53,1.49c-2.64.24-5.37.61-7.42-.64l-.56-.34c.37,0,.52-.6.99-.96.23-.18-.52-.27-.45-.87l-1.23-17.67c-.63-9.07-.23-17.95-2.92-26.95l-2.35,17.78-4.21,27.18,3.26.18c-1.16,2.56-3.95,2.42-6.56,2.51l-13.67.49-16.87,1.08c-.05-.45-.49-.46-.65-.67l-.4-.38-.4-.38-.4-.38.58-.54.2.56c2.01-.21.19-3.36.22-9.43,0-2.13-.72-4.28.05-6.44.35-.98,2.07-.69,2.87-.56.43-1.64-1.95-1.67-2.13-2.33l.95-10.42c.09-.94.1-1.71-.31-2.02-1.64-1.25-3.51.15-5.41-.22.06-.7-.29-1.6-.74-2.1-.64-.71-1.77-.71-2.91-.62l-12.92,1.07.18-1.34s-.25-1-.63-1.37l-3.78-3.63c-1.39-1.34-2.03-2.83-1.58-5.16l-3.26-3.67c-1.51-1.7-2.88-4.39-.81-6.29,1.1-.32,3.6.81,4.46-.47,2.86-4.24-1.31-5.58-.49-7.74,1.33-3.48-4.58-6-4.26-8.5l13.01-2.5c3.67-1.59,6.78-3.8,10.59-3.22l.1,32.81c0,1.01,1.11,1.93,1.68,2.01,2.03.27,1.28-3.03,2.08-6.24,2.13.46-1.02,4.51,1.57,8.01.45.61,1.46,0,1.84-.26.62-.41.57-1.41.77-2.39l1.09-.2c.09-.02.41-.44.4-.93l-.43-14.35-.45-23.53c-.01-.52-.02-1.19.34-1.48.58-.46,1.53-.32.75.63l8.86,15.58,13.62,24.51c.37.67,1.86,1.46,2.5,1.41.85-.07,1.58-1.73,1.05-2.73-.13-1.47.35-1.05,1.48-.63,1.09-.52,1.88.67,2.48.34.91-.51,1.18-1.31,1.03-2.48-1.7-13.24-2.12-26.26-1.41-39.55l.91-17.03c.05-.93-.07-1.64-.43-2.01-.49-.49-1.44-.78-1.91-.55-.75.36-1.15,1.14-1.13,2.02l.19,7.68h-.86s-.48-6.72-.48-6.72c-.08-1.09-1.4-1.77-2.14-1.88-1.31-.2-2.03,1.16-2.03,2.59l-.14,40.7c-1.18.02-.63-.49-.97-1.13-7.29-13.26-14.57-26.19-20.99-39.77-.21-.45-.86-.62-1.11-.66-.4-.05-.83.64-1.21.96l-.5-2.31c-.16-.75-1.63-1.47-2.45-.7-1.35,1.27-.5,3.3-.84,4.65-1.25-.1-.52-1.14-.93-1.47-.51-.4-1.14-.55-2.26-.76l-.37,3.69c-2.89-2.03-5.73-4.3-9.04-6.32-.72-.34-2.29.61-2.32,1.39-.03,1.15-.89,2.45.54,3.02l10.66,3.95.41,19.15-14.09-.14c-5.14-.17-9.74-2.39-14.67-4.07-.85.62-1.32,1.36-2.39.69l-1.67-18.92c-.18-2.03.78-3.84,2.65-4.71l7.42-3.44-5.93-5.13.16-5.46c-1.39-3.37-3.58-6.69-3.35-10.39l1.05-16.95c.05-.8-.56-1.46-.02-2.1s1.18.05,2.02.15l15.08,1.79,14.48.61,27.87-1.53c2.78-.15,4.85.21,6.45,2.28-.81,1.03-1.69-.01-3.26,0l4.13,7.93c4.58,11.64,3.97,31.83,8.32,46.37l-.45-23.47,1.02-30.81c-.29-.23-.77.13-1.3.05-.68-.1.4-.6.29-1.64l-.63.29.63-.29,16.21-1.53,9.43-.31c4.27-.14,8.52-1.66,12.53-.39l.66.23c-.03.16-.03.22-.03-.04ZM183.3,260.1c.11.72.67,3.38,2.19,2.35,1.76-7.21-5.95-14.22-14.14-15.14-14.41-1.63-23.52,15.84-26.38,29.41-2.46,11.72.92,27.85,11.7,33.33,3.81,1.94,8.55,3.2,12.6,2.71,17.38-2.11,23.73-18.78,19.2-17.57-1.69.45-1.96,3.32-2.63,3.58-2.37.93.71-2.54.04-4.79-.24-.81-1.17-1.31-1.71-.96-2.42,1.56-3.83,8.02-11.84,10.21-7.31,2.01-14.11-2.14-16.6-9.5-6.23-18.48,5.85-40.89,17.78-36.88,5.29,1.78,5.29,9.24,6.76,9.31,2.39.13,2.79-4.52,2.18-6.12l.85.05Z"/>
|
||||||
|
<path class="cls-5" d="M97.21,275.95c.18.35.62.79.03.98-3.81-.59-6.93,1.63-10.59,3.22l-13.01,2.5c-.32,2.5,5.59,5.02,4.26,8.5-.82,2.16,3.35,3.5.49,7.74-.86,1.28-3.37.15-4.46.47-2.07,1.9-.69,4.59.81,6.29l3.26,3.67c-.45,2.32.19,3.82,1.58,5.16l3.78,3.63c.38.37.64,1.34.63,1.37l-.18,1.34c-5.9.49-11.78-1.34-17.6-2.56,0,.16,0,.27,0,.02l-.69-.73c.06-1.46-.73-4.12-.11-5.67.88-2.2.34-3.61.48-5.56l1.71-23.37c.25-3.49-1.22-7.09-1.53-10.53,1.07.67,1.54-.07,2.39-.69,4.93,1.69,9.53,3.9,14.67,4.07l14.09.14Z"/>
|
||||||
|
<path class="cls-6" d="M83.8,320.81l12.92-1.07c1.13-.09,2.26-.09,2.91.62.45.5.8,1.4.74,2.1-.91,10.45-.01,20.93,3.56,30.87l-.58.54-36.74-35.2-.4-.41c5.82,1.22,11.7,3.05,17.6,2.56Z"/>
|
||||||
|
<path class="cls-6" d="M179.77,205.02c0,.18,0,.24.03.04l34.21,33.44-.49.54-1.92-.35c-.22-.04-.45-.09-.64-.11l-2.66-.27c-13.98-3.98-24.18.77-25.99-.65-.73-.57-.59-1.87-.82-2.93,1.42-8.61.58-16.85-.87-25.09.02-.14-.04-.38-.08-.59l-.77-4.04Z"/>
|
||||||
|
<path class="cls-5" d="M149.77,353.23l-.56-.34-3.01-2.1c-1.49-1.04-3.18-.66-3.91.8l-3.26-.18,4.21-27.18,2.35-17.78c2.69,9,2.29,17.87,2.92,26.95l1.23,17.67c-.07.59.69.68.45.87-.47.37-.62.97-.99.96l.56.34Z"/>
|
||||||
|
<path class="cls-5" d="M140.36,207.35l.63-.29c.11,1.04-.97,1.54-.29,1.64.53.08,1.01-.28,1.3-.05l-1.02,30.81.45,23.47c-4.34-14.54-3.74-34.72-8.32-46.37l-4.13-7.93c1.57-.02,2.45,1.02,3.26,0,1.15,1.48,3.05,3.31,5.03,3.99l3.08-5.27Z"/>
|
||||||
|
<path class="cls-5" d="M213.94,271.89c-1.02,3.42-5.64,5.2-4.81,6.04l3.77,3.83c.8.81.39,1.56.68,2.19l-7.37-.46-22.92-3.07-16.47-2.61c3.84-2.08,7.46-1.25,11.12-1.38l16.99-.61,6.5-.84c3.49-.45,6.7-.95,9.57-2.78.84-.53,2.19-.22,2.94-.31Z"/>
|
||||||
|
<path class="cls-5" d="M214.34,258.7c-.77.21-1.64-.54-2.66.28-2.59-1.73-3.44-4.65-4.84-7.45-.96-1.92-2.99-3.34-3.86-5.38-1.79-4.15-5.61-3.09-6.63-4.83l14.51-.85c-.03-.62-.15-1.25.1-1.89.19.02.43.07.64.11l1.92.35.49-.54.4.39c.14.14.38.23.4.39l.1.72c.48,3.66-.57,7.44-.57,11.31v7.39Z"/>
|
||||||
|
<path class="cls-1" d="M96.95,254.77l.37-3.69c1.12.21,1.75.37,2.26.76.42.33-.32,1.37.93,1.47.34-1.34-.52-3.38.84-4.65.82-.77,2.29-.05,2.45.7l.5,2.31c.37-.32.81-1.01,1.21-.96.25.03.9.2,1.11.66,6.43,13.58,13.7,26.51,20.99,39.77.33.64-.22,1.15.97,1.13l.14-40.7c0-1.44.72-2.79,2.03-2.59.75.11,2.07.79,2.14,1.88l.48,6.73h.86s-.19-7.69-.19-7.69c-.02-.88.38-1.66,1.13-2.02.47-.23,1.41.06,1.91.55.36.36.48,1.08.43,2.01l-.91,17.03c-.71,13.29-.29,26.3,1.41,39.55.15,1.17-.12,1.97-1.03,2.48-.6.34-1.39-.86-2.48-.34-1.13-.42-1.61-.84-1.48.63.53,1-.2,2.66-1.05,2.73-.63.05-2.13-.74-2.5-1.41l-13.62-24.51-8.86-15.58c.78-.95-.17-1.09-.75-.63-.36.29-.35.96-.34,1.48l.45,23.53.43,14.35c.01.49-.31.91-.4.93l-1.09.2c-.2.98-.16,1.98-.77,2.39-.38.26-1.39.87-1.84.26-2.59-3.5.56-7.55-1.57-8.01-.8,3.22-.05,6.51-2.08,6.24-.58-.08-1.68-1-1.68-2.01l-.1-32.81c.59-.19.15-.63-.03-.98l-.41-19.15c-.01-.67.09-1.35.16-2.03ZM131.61,294.87c.56.17.86.1,1.15.05l-.14-5.48h-.88s-.12,5.43-.12,5.43Z"/>
|
||||||
|
<path class="cls-3" d="M183.3,260.1l-.32-2.09c-.11-.72-1.11-1.15-.97-1.75l-3.04-3.05c-.51-.51-.92-.45-1.63-.42,2.53,1.87,4.03,4.39,5.11,7.26.61,1.6.21,6.25-2.18,6.12-1.47-.08-1.46-7.54-6.76-9.31-11.93-4-24.01,18.41-17.78,36.88,2.48,7.36,9.28,11.51,16.6,9.5,8-2.2,9.41-8.66,11.84-10.21.54-.35,1.47.15,1.71.96.66,2.24-2.41,5.72-.04,4.79.67-.26.94-3.13,2.63-3.58,4.53-1.21-1.83,15.46-19.2,17.57-4.05.49-8.79-.77-12.6-2.71-10.78-5.48-14.16-21.61-11.7-33.33,2.86-13.58,11.96-31.05,26.38-29.41,8.2.93,15.9,7.93,14.14,15.14-1.52,1.03-2.08-1.63-2.19-2.35Z"/>
|
||||||
|
<path class="cls-4" d="M180.62,209.65c1.45,8.24,2.29,16.48.87,25.09.23,1.06.09,2.35.82,2.93,1.81,1.42,12.02-3.33,25.99.65l2.66.27c.19.02.43.07.64.11-.22-.04-.45-.09-.64-.11-.26.64-.13,1.27-.1,1.89l-14.51.85c-7.53.44-16.11,2.54-17.16.75-2.65-4.48-1.82-9.62-1.41-14.45l.89-10.4-.79-8.42c1.56-.33,1.82,1.45,2.67.25.04.21.1.45.08.59Z"/>
|
||||||
|
<path class="cls-4" d="M100.37,322.45c1.9.37,3.77-1.03,5.41.22.41.31.4,1.07.31,2.02l-.95,10.42c.18.66,2.56.69,2.13,2.33-.8-.12-2.52-.42-2.87.56-.77,2.16-.04,4.31-.05,6.44-.03,6.07,1.79,9.23-.22,9.43l-.2-.56c-3.58-9.94-4.47-20.41-3.56-30.87Z"/>
|
||||||
|
<path class="cls-5" d="M96.95,254.77c-.07.68-.17,1.36-.16,2.03l-10.66-3.95c-1.43-.57-.57-1.87-.54-3.02.02-.78,1.6-1.73,2.32-1.39,3.31,2.02,6.15,4.3,9.04,6.32Z"/>
|
||||||
|
<path class="cls-4" d="M183.3,260.1l-.85-.05c-1.08-2.87-2.59-5.4-5.11-7.26.71-.03,1.12-.09,1.63.42l3.04,3.05c-.15.6.86,1.03.97,1.75l.32,2.09Z"/>
|
||||||
|
<path class="cls-4" d="M131.61,294.87l.12-5.43h.88s.14,5.47.14,5.47c-.29.05-.59.12-1.15-.05Z"/>
|
||||||
|
<path class="cls-2" d="M179.77,205.02l-.62-.2c-4-1.27-8.26.25-12.53.39l-9.43.31-16.21,1.53-.63.29-3.08,5.27c-1.99-.68-3.89-2.51-5.03-3.99-1.6-2.07-3.66-2.43-6.45-2.28l-27.87,1.53-14.48-.61-15.08-1.79c-.84-.1-1.46-.81-2.02-.15s.07,1.3.02,2.1l-1.05,16.95c-.23,3.7,1.96,7.02,3.35,10.39l-.16,5.46,5.93,5.13-7.42,3.44c-1.86.87-2.83,2.67-2.65,4.71l1.67,18.92c.3,3.43,1.78,7.04,1.53,10.53l-1.71,23.37c-.14,1.96.39,3.36-.48,5.56-.62,1.55.17,4.21.11,5.67l.69.73"/>
|
||||||
|
<path class="cls-2" d="M214.8,239.27l-1.28-.24-1.92-.35c-.22-.04-.45-.09-.64-.11l-2.66-.27c-13.98-3.98-24.18.77-25.99-.65-.73-.57-.59-1.87-.82-2.93,1.42-8.61.58-16.85-.87-25.09.02-.14-.04-.38-.08-.59l-.77-4.04"/>
|
||||||
|
<path class="cls-2" d="M104.54,355.01l-.41-1.13-.2-.56c-3.58-9.94-4.47-20.41-3.56-30.87.06-.7-.29-1.6-.74-2.1-.64-.71-1.77-.71-2.91-.62l-12.92,1.07c-5.9.49-11.78-1.34-17.6-2.56"/>
|
||||||
|
<polyline class="cls-2" points="179.8 205.06 214.01 238.49 214.41 238.88 214.8 239.27"/>
|
||||||
|
<path class="cls-2" d="M214.8,239.27l.1.72c.48,3.66-.57,7.44-.57,11.31v7.39s.07,6.27.07,6.27c.03,2.58.2,4.71-.47,6.93-1.02,3.42-5.64,5.2-4.81,6.04"/>
|
||||||
|
<polyline class="cls-2" points="66.21 318.28 66.6 318.66 103.34 353.86 103.74 354.24 104.14 354.63 104.54 355.01"/>
|
||||||
|
<path class="cls-2" d="M210.79,316.19c2.01,1.62,5.55,12.81,5.16,16.41l-2.13,19.73c-8.18,1.27-16.4,2.09-24.72,1.88l-6.37-8.74c-.46-.63-1.12-1.06-1.64-1.08-.67-.03-1.56.51-2.14,1.26l-5.23,6.74-16.53,1.49c-2.64.24-5.37.61-7.42-.64l-.56-.34-3.01-2.1c-1.49-1.04-3.18-.66-3.91.8-1.16,2.56-3.95,2.42-6.56,2.51l-13.67.49-16.87,1.08-.65-.67"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.6 KiB |
@@ -0,0 +1,49 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #9b1f0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #3a160a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #e93525;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #3d180d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: #c12b1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #381507;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2.2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-3" d="M141.35,207.84c-.15-.1-.38-.08-.57-.14-.5.53.56,1.73-.35,2.63,2.2,6.52,2.73,13.17,3.12,20.16l1.18,20.95c.3-.07.67-.04.84-.02.7-.89-.2-2.18-.08-3.4l3.51-37.22c.07-.78-.41-1.44-.5-1.88l19.5-2.14c1.96.84,1.89,4.16,2.43,5.54-.19-.36-1.21.01-1.82.21-.39.13.11.72-.4,1.5.58,10.03-3.74,18.54-4.28,28.36,3.88-6.54,14.81-30.94,15.78-31.51,1.1-.65,1.54.47,2.43.78,1.28.45,3.34-.37,3.73,1.34.25,1.09,1.66.7,2.44,1.56-.46,1.49-1.33,2.83-1.48,4.39,3.42.29,7.69-.88,11.02.3,2.37.84-.91,3.18-1.22,4.37.17,4.04,4.98,4.47,10.21,3.99.82.43.92,2.65,2.23,2.64l-4.61,3.64c4.87-.22,8.3,1.84,11.15,1.44l-.61,4.42c-.69.13-1.97-.34-2.8.24-6.06,4.22-12.42,7.63-18.95,11.04l-6.29,4.39c4.87.92,9.07-1.38,13.76-2.48,3.75-.88,7.71-1.15,11.61-1.17,1.47,0,3.02,1.79,4.47.65l-1.19,12.44c-.25,2.67-.6,5.14-1.34,7.45-.83-.98-1.63-.31-2.63.07-6.58,2.5-13.24,3.89-20.13,5l-17.39,2.78c1.75,1.3,3.52.34,5.24.34h32.08c-1.46,8.73-7.23,6.14-7.99,8.08-1.21,3.1,2.92,5.92,1.74,7.9s-3.15,3.27-4.07,5.7c2.08.02,5.13-3.57,7.39-2.41.77.39,1.39,1.77,1.11,3.19-3.36,4.96-9.42,5.44-8.61,13.35l-.74,3.75c-1.58.06-2.1,1.03-2.21,2.08l-12.24-1.78c-1.47-.21-4.87-.09-4.63,1.94l1.6,13.76c.17,1.43.76,2.77-.25,3.79l-5.27,2.54-2.14.63-1.93-1.16c-6.66,3.78-4.71,7.07-15.02,6.31l-11.95,2.56-1.39-7.83c-3.15-7.67-3.81-15.27-3.95-23.41l-.15-8.71c-.38.04-.44-.34-.87-.53l-1.62,20.27c-.21,2.63-.8,4.53-1.38,7.39l-20.37-.61c-2.55-.08-4.58-3.05-7.89-1.97-.6-2.94-2.88-3.36-5.33-3.82.34-4.53,4.71-6.78,3.51-8.96l-7.49,7.73-1.13-2.09c-1.15-2.13-1.4-5.14-4.15-6.09-.73-1.72-2.1-3.05-4.58-2.9-.17-1.75-2.45-1.79-2.86-2.71-.24-.54.43-.87-.61-.57-.2.06-3.02-3.72-3.62-7.19-3.05-.69-.25-4.22-2.63-4.24,3.16-3.19-3.84-1.11-4.81-3.41-.81-1.91-.04-3.39.74-4.96.26-.51-.56-4.29,2.44-9.1,6.33-2.1,12.4-3.33,18.8-4.23.25-.04.88.64,1.17.5.34-.16.76-.41.89-1.14l-22.09-1.45-1.42-.42c.68.2-2.27-4.82-.93-7.13,1.88-.95,4.57-.3,6.65-.77.49-.11,4.68-5.32,4.6-4.85.15-.81-.35-1.49-.9-1.62-1.08-.25-1.49,2.01-5.22,2.29-1.4.11-3.82-3.52-3.86-3.9-.09-.81.64-.76,1.09-1.56,3.25-5.81,2.03-5.22,1.61-9.67-.13-1.35-1.69-2.4-.19-3.82,2.65-2.49,5.15-5.35,6.94-8.32.25-.1.64-.51,1.11-.47l13.86,1.31c3.55-2.76,1.46-9.93,1.27-15.68l-.49-14.85c.39-.69.33-1.35.37-1.85.06-.7-.98-1.5-1.55-.94l.55-2.93.11-.58.22-1.16-.39.4.39-.34c4.32.66,9.46,1.37,13.92,1.69l14.97,1.06c3.88.27,7.66-.03,11.21,1.08.19.06.42.03.57.14ZM102.09,290.2l.93,21.66c.06,1.44-.52,3.82,1.01,4.64.98.52,2.05,1,2.45.38l1.25-1.93c.34-.52-.14-1.15.09-1.4.18-.19.64-.12.85-.03.32.12.23.16.26.39.08.53-.64.83-.5,1.16.41.96,1.63,1.3,2.11.96.43-.3,1.27-1.06,1.24-1.95l-.87-22.21c8.59-.6,16.52-3.59,22.83-8.98,7.59-6.48,9.09-17.7,2.59-25.38s-21.36-8.59-30.78-5.14c-.19-1.38-.35-2.08-.8-2.15l-2.07-.29-.32,3.56c-9.69,4.25-8.22,8.19-6.55,7.13.37-.23.75-.79,2.02-.94l-1.7,2.61c-.33.5-.28,1.36.15,1.65.29.2,1.12.33,1.58,0l4.17-3.06-.03,24.78c-1.77.34-3.13-1.12-4.55-.4-1.95.98.56,3.2,4.63,4.93ZM183.9,295.43c-2.54-1.64-4.08,8.31-13.8,10.5-5.05,1.14-10.5-1.01-13.47-5.57-4.78-7.32-5.07-16.43-2.58-24.6,3.13-10.28,10.83-20.05,19.27-15.53,5.81,3.12,2.84,9.38,6.76,8.1.76-.25,1.62-3.66.55-6.94,1.78.38,1.35,3.89,2.76,4.16,2.82.54.95-11.97-10.88-15.11-17.71-4.7-30.43,20.87-29.61,38.76.36,7.72,2.8,15.24,8.44,20.4,5.98,5.47,14.37,6.55,22.33,4.17,9.83-2.94,16.54-13.86,14.45-15.95-.52-.53-1.42-.6-1.75-.07l-.88,1.4c-.87.17-.68.78-.87.89-1.52.83,1.03-3.48-.73-4.61Z"/>
|
||||||
|
<path class="cls-5" d="M100.35,205.56l-.55,2.93c-1.08,5.75-2.71,11.68-2.27,17.86.25,3.54.85,6.96.03,10.68-8.89.06-16.45-2.38-25.75.39-.64.15-.28,1.16-.01,1.44.24.26.66-.03,1.36.22l11.41,1.34c.18.5.36.67.67.55-1.8,2.97-4.29,5.82-6.94,8.32-1.5,1.41.06,2.46.19,3.82.42,4.45,1.64,3.85-1.61,9.67-.45.81-1.18.76-1.09,1.56.04.39,2.46,4.01,3.86,3.9,3.73-.29,4.14-2.55,5.22-2.29.56.13,1.05.81.9,1.62.09-.47-4.1,4.74-4.6,4.85-2.08.48-4.77-.18-6.65.77-1.35,2.31,1.61,7.33.93,7.13l1.42.42,22.09,1.45c-.13.74-.55.98-.89,1.14-.29.14-.91-.53-1.17-.5-6.4.9-12.47,2.12-18.8,4.23-3,4.81-2.18,8.59-2.44,9.1-.78,1.57-1.55,3.04-.74,4.96.97,2.29,7.97.21,4.81,3.41,2.38.02-.42,3.55,2.63,4.24.6,3.47,3.42,7.25,3.62,7.19,1.04-.3.38.02.61.57.4.92,2.69.96,2.86,2.71,2.48-.16,3.85,1.18,4.58,2.9,2.75.95,3,3.96,4.15,6.09l1.13,2.09,7.49-7.73c1.2,2.18-3.16,4.43-3.51,8.96,2.45.45,4.73.87,5.33,3.82,3.31-1.07,5.34,1.9,7.89,1.97l20.37.61c.58-2.85,1.17-4.75,1.38-7.39l1.62-20.27c.43.19.49.57.87.53l.15,8.71c.14,8.14.8,15.74,3.95,23.41l1.39,7.83,11.95-2.56c10.32.76,8.36-2.53,15.02-6.31l1.93,1.16,2.14-.63,5.27-2.54c.4-.02.63.4.83.64.16.18-.17.6-.2,1.04l-.67,10.12c.82-.14,1.5-.45,2.27,0,2.81-6.65,3.02-13.87,2.67-21.4.15-.92-.09-2.34.39-2.85.45-.48,1.84-1.22,2.79-1.11,7.02.82,13.69.47,20.29-1.35,1.03.54,2.17.4,3.18.19.08.61.02,1.31-.5,1.83l-14.08,13.99c-3.04,3.02-6.73,5.26-9.07,8.88l-3.1,3.24c-1.03-1.01-2.03-.45-2.82-.97l-2.06,4.85-35.37-1.32c-4.34-1.79-6.54-8.04-7.73-7.92s-2.8,6.38-5.48,6.54l-10.41.64c-5.38.33-10.15.72-15.49,2.19-3.14.87-7.62.86-10.83.08-2.95-1.18-3.85-5.14-6.2-6.63-2.58.57-3.1,5.36-5.17,6.12s-15.98-.69-16.94-1.64c-1.04-1.02-1.66-2.74-1.93-4.36l-1.46-8.74c-.26-1.57-1.4-2.96-.05-5l.25-3.57c1.08-2.07.21-4.64.52-7.05l1.27-9.71c.24-1.86.5-4.12-.13-5.95l-.38-7.85c-.09-1.91-.47-3.76-.13-5.55l1.4-7.4c.42-2.24,1.09-4.38,1.82-6.51-.7-1.43-2.17-2.49-2.25-3.91l-.41-7.84-.51-7.74-.6-8.71-.3-7.65c-.86-4.02-1.08-8.37,2.47-11.45.28-.11.66-.01.92.02l30.19-31.05.84.55Z"/>
|
||||||
|
<path class="cls-5" d="M215.61,235.34c-2.85.4-6.28-1.66-11.15-1.44l4.61-3.64c-1.3,0-1.41-2.21-2.23-2.64-5.23.47-10.04.05-10.21-3.99.31-1.19,3.58-3.53,1.22-4.37-3.33-1.18-7.6,0-11.02-.3.14-1.57,1.01-2.91,1.48-4.39-.78-.86-2.19-.47-2.44-1.56-.39-1.71-2.45-.89-3.73-1.34-.89-.32-1.33-1.43-2.43-.78-.97.57-11.91,24.97-15.78,31.51.54-9.82,4.86-18.33,4.28-28.36.51-.78,0-1.37.4-1.5.61-.2,1.63-.57,1.82-.21.52,1.34,1.39,2.32,2.25,3.36l.35.42-.35-.42c2.99-4.01,4.32-9.78,6.79-11.04,1.99-1.01,6.98.63,10.05.99,2.25.26,4.67-.67,7.13.45l5.57.52c4.86.45,13.52,1.2,14.2,1.84.95.89.45,1.58.67,2.86,1.43,8.35.85,16.35-1.48,24.06Z"/>
|
||||||
|
<path class="cls-5" d="M214.27,272.31c-.4,1.25-2.02,2.41-1.74,4.07l2.61,3.32,1.18,21.98,1.27,18.14c.18,2.6-1.32,4.68-3.5,5.32-1,.22-2.14.35-3.18-.19.51-.44.48-1.12.22-2.05-4.36.28-8.77-.13-13.07-.76.11-1.05.62-2.02,2.21-2.08l.74-3.75c-.81-7.92,5.25-8.4,8.61-13.35.28-1.42-.34-2.79-1.11-3.19-2.26-1.16-5.3,2.44-7.39,2.41.92-2.43,2.85-3.65,4.07-5.7s-2.95-4.8-1.74-7.9c.76-1.94,6.53.65,7.99-8.08h-32.08c-1.72,0-3.48.96-5.24-.34l17.39-2.78c6.89-1.1,13.55-2.49,20.13-5,1-.38,1.8-1.05,2.63-.07Z"/>
|
||||||
|
<path class="cls-1" d="M148.5,208.91c.09.44.57,1.1.5,1.88l-3.51,37.22c-.12,1.22.79,2.51.08,3.4-.17-.02-.55-.05-.84.02l-1.18-20.95c-.39-6.98-.92-13.64-3.12-20.16.91-.89-.15-2.09.35-2.63.19.06.42.03.57.14,2.12,1.41,4.66,1.34,7.15,1.07Z"/>
|
||||||
|
<path class="cls-5" d="M215,239.76c-.37,2.69-1.06,4.75-4.09,5.88,0,1.06-1.32,1.19-1.19,1.7.25,1,1.38,1.43,2.04,1.7,1.6.65,5.23,1.29,5.03,3.38-1.45,1.14-3-.66-4.47-.65-3.89.02-7.85.3-11.61,1.17-4.69,1.1-8.9,3.39-13.76,2.48l6.29-4.39c6.53-3.41,12.89-6.83,18.95-11.04.83-.58,2.12-.11,2.8-.24Z"/>
|
||||||
|
<path class="cls-2" d="M102.09,290.2c-4.07-1.73-6.58-3.95-4.63-4.93,1.42-.72,2.78.75,4.55.4l.03-24.78-4.17,3.06c-.46.33-1.29.2-1.58,0-.43-.29-.48-1.15-.15-1.65l1.7-2.61c-1.27.15-1.65.71-2.02.94-1.68,1.06-3.15-2.89,6.55-7.13l.32-3.56,2.07.29c.45.06.61.77.8,2.15,9.43-3.46,24.09-2.78,30.78,5.14s5,18.9-2.59,25.38c-6.31,5.39-14.24,8.38-22.83,8.98l.87,22.21c.03.89-.81,1.65-1.24,1.95-.48.34-1.7,0-2.11-.96-.15-.34.58-.64.5-1.16-.04-.23.06-.27-.26-.39-.21-.08-.67-.16-.85.03-.23.25.25.88-.09,1.4l-1.25,1.93c-.4.62-1.47.15-2.45-.38-1.53-.82-.95-3.2-1.01-4.64l-.93-21.66ZM131.84,268.69c-.17-9.31-11.39-12.74-20.83-10.93-.29,9.08-1.11,17.3-.24,26.28,9.58-2.04,21.23-6.33,21.06-15.35Z"/>
|
||||||
|
<path class="cls-4" d="M183.9,295.43c1.76,1.14-.79,5.45.73,4.61.19-.11,0-.72.87-.89l.88-1.4c.33-.52,1.23-.45,1.75.07,2.08,2.09-4.63,13.02-14.45,15.95-7.96,2.38-16.35,1.3-22.33-4.17-5.64-5.17-8.09-12.68-8.44-20.4-.82-17.89,11.9-43.46,29.61-38.76,11.84,3.14,13.7,15.66,10.88,15.11-1.41-.27-.98-3.78-2.76-4.16,1.07,3.28.2,6.69-.55,6.94-3.93,1.28-.95-4.98-6.76-8.1-8.45-4.53-16.14,5.24-19.27,15.53-2.49,8.17-2.2,17.28,2.58,24.6,2.98,4.56,8.43,6.7,13.47,5.57,9.71-2.19,11.26-12.13,13.8-10.5ZM177.02,256.36l3.08,4.12c1.58-.8-.62-4.44-3.08-4.12Z"/>
|
||||||
|
<path class="cls-1" d="M198.07,322.14c4.3.63,8.71,1.03,13.07.76.25.92.29,1.6-.22,2.05-6.61,1.82-13.27,2.17-20.29,1.35-.95-.11-2.34.63-2.79,1.11-.48.51-.23,1.94-.39,2.85.34,7.53.14,14.75-2.67,21.4-.77-.46-1.44-.14-2.27,0l.67-10.12c.03-.44.36-.86.2-1.04-.2-.24-.43-.66-.83-.64,1.02-1.02.42-2.37.25-3.79l-1.6-13.76c-.24-2.03,3.16-2.15,4.63-1.94l12.24,1.78Z"/>
|
||||||
|
<path class="cls-1" d="M85.24,240.96c-.31.13-.49-.05-.67-.55l-11.41-1.34c-.71-.24-1.12.04-1.36-.22-.26-.28-.63-1.29.01-1.44,9.3-2.77,16.87-.34,25.75-.39.82-3.72.22-7.14-.03-10.68-.44-6.17,1.19-12.1,2.27-17.86.58-.57,1.61.24,1.55.94-.04.5.02,1.16-.37,1.85l.49,14.85c.19,5.76,2.28,12.92-1.27,15.68l-13.86-1.31c-.47-.04-.87.37-1.11.47Z"/>
|
||||||
|
<path class="cls-3" d="M131.84,268.69c.17,9.02-11.48,13.31-21.06,15.35-.87-8.98-.05-17.2.24-26.28,9.44-1.81,20.65,1.63,20.83,10.93Z"/>
|
||||||
|
<path class="cls-1" d="M177.02,256.36c2.46-.32,4.66,3.32,3.08,4.12l-3.08-4.12Z"/>
|
||||||
|
<path class="cls-6" d="M211.77,249.04c1.6.65,5.23,1.29,5.03,3.38l-1.19,12.44c-.25,2.67-.6,5.14-1.34,7.45-.4,1.25-2.02,2.41-1.74,4.07l2.61,3.32,1.18,21.98,1.27,18.14c.18,2.6-1.32,4.68-3.5,5.32-1,.22-2.14.35-3.18-.19-6.61,1.82-13.27,2.17-20.29,1.35-.95-.11-2.34.63-2.79,1.11-.48.51-.23,1.94-.39,2.85.34,7.53.14,14.75-2.67,21.4-.09.1-.19.28-.26.44l-2.06,4.85-35.37-1.32c-4.34-1.79-6.54-8.04-7.73-7.92s-2.8,6.38-5.48,6.54l-10.41.64c-5.38.33-10.15.72-15.49,2.19-3.14.87-7.62.86-10.83.08-2.95-1.18-3.85-5.14-6.2-6.63"/>
|
||||||
|
<path class="cls-6" d="M100.69,203.88c4.32.66,9.46,1.37,13.92,1.69l14.97,1.06c3.88.27,7.66-.03,11.21,1.08.19.06.42.03.57.14,2.12,1.41,4.66,1.34,7.15,1.07l19.5-2.14c1.96.84,1.89,4.16,2.43,5.54s1.39,2.32,2.25,3.36l.35.42"/>
|
||||||
|
<path class="cls-6" d="M100.68,203.82l-.39.4-.78.79-30.19,31.05c.27,1.12,1.58,1.24,2.49,1.36,9.3-2.77,16.87-.34,25.75-.39.82-3.72.22-7.14-.03-10.68-.44-6.17,1.19-12.1,2.27-17.86l.55-2.93.11-.58.22-1.16Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,48 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #0db3c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #007988;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #0c96a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #3a170d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #381507;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: #3c1b0d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-1" d="M214.65,239.1l-2.58-.1c-3.79,2.1-7.73,4.08-11.61,6.32l-6.56,3.79c-2.2,1.27-5.02,2.22-6.12,5.03l12.97-3.01,11.9-1.19c.44-.63.06-1.58.21-2.2,1.11.52,3.9,1.16,3.71,3.1l-1.82,18.71c-1.5,1.84-3.42,2.56-4.11,4.45-.39.04-.84-.28-1.1-.57-.2-.23.18-1.3-.21-1.23l-13.32,2.67-24.66,3.99c2.94.91,5.47.34,8.11.31l30.61-.32c.58,0,1.24.32,1.55.45s.06,1.13-.11,1.49l-5.94,12.1-2.34,5.89c1.31.57,2.15-1.03,3.19-1.23,1.71-.34,2.18,1.87,2.29,2.91.19,1.76-.21,2.53-1.63,3.75l-3.61,3.1c-2.25,1.93-2.51,5.48-3.11,8.25l-2.36,4.83-14.21-1.65c-1.16-.13-2.3.56-2.59,1.21-1.13,2.5,3.49,19.87,1.51,29.66l2.38-.32.4,1.88c-.02,1.11-.76,2.05-1.49,2.86-.9,1-2.37.8-3.88.8l-24.17.07c-3.64.01-6.98.15-10.19-1.01l-.55-.2c.38-.35-.12-1.32,1-2.02l-3.02-10.07c-.55-1.82-.65-4.03-.68-6.05l-.38-23.49c0-.35.21-1.3-.12-1.78-.19-.26,1.36-1.02-.61-1.17l-4.19,32.02c-.53,4.08-2.01,7.72-1.64,11.98,1.56-.87,2.59-.64,3.6-.38-1.06.63-1.9,2.74-3.63,3-8.84,1.34-17.96,1.85-26.77.44l-13.88-3.27c-1.56-1.74-2.19-3.54-3.98-4.71l-6.41,7.6c-5.24-.33-10.27-1.31-15.49-2.21-2.15-4.26-.12-10.16-1.62-14.2-1.64-4.39-1.98-8.67-1.04-13.21,1.9-9.16,1.37-17.8.49-27.02-.43-4.52,3.2-9.56,5.02-12.83l.8-.58.55-.55-.55.55-.8.58c.11.87,1.36,1.26,2.07.83.33-.2.87.26,1.48-.62l30.23-5.39c1.79-.32,6.53.08,5.59-1.14-.12-.15-.41-.79-.78-.81l-16.48-.56-11.54.04c-3.89-.56-7.57.1-11.44-1.87.21,1.09-.13,1.57-.76,1.25-.22-.11-.39.11-.92.03l-2.78-19.95c-.32-2.3-1.51-5.82-.08-7.61l6.94-4.85c.64-.45.02-1.82-.8-1.97-2.71-1.37-4.11-3.91-2.9-7.27-2.45-4.5-2.75-9.25-2.39-14.17l.97-13.21c.38-5.16,5.65-2.67,12.92-.43,3.25.54,6.3-.23,9.33-1.63,8.42.55,16.41-.06,24.8-.67l16.36-1.2,8.59,1.05c.2.02.38.14.56.21-.19-.07-.37-.18-.56-.21-.54,4.1,1.56,7.36,1.72,9.19l1.11,13.13.8,17.66-.2,8.37c.25,0,.57-.14.92.02l3.92-47.24-.57.22.57-.22,19.53-1.91c1.57.6,1.43,3.49,2.12,4.44.09.12.09.4.12.6-.03-.2-.03-.48-.12-.6l-2.15.91c.17,8.51-1.44,13.81-3.15,21.63l-2.13,9.75c2.92-2.92,3.58-6.64,5.27-10.02l10.81-21.63c.85-1.69.49-4.05,3.09-4.69-.5-.7-.42-1.53-.6-2.46,1.54.33,3.13-.5,4.85.26l5.72.4,10.88,1.12,6.13.57c2.16.2,6.59-.53,8.1,2.25.4,6.45,1.14,13.48-.1,19.79l-2.24,11.36ZM168.83,303.16c-6.94.18-12.13-5.11-13.68-11.47-1.72-5.03-2.28-10.32-.88-15.55,1.12-10.54,9.73-25.45,20.19-20.69,5.19,2.36,4.68,11.87,7.33,8.34-.13.18.87-3.46,1.06-3.05-.47-1.02-.68-2.77-.56-2.97.38-.62.92-.03.9.33-.01.16.1.13.26.77.43.68.42,1.72.95,1.95,3.46,1.48,1.8-9.41-6.67-13.59-5.47-2.7-10.86-3.08-15.84-.29-7.33,4.11-11.48,11.04-14.58,18.6-3.51,8.58-5.17,17.76-2.65,26.66,1.55,5.5,3.59,11.18,8.36,15.03,6.06,4.89,14.44,6.2,22.41,3.63,13.87-4.48,18.3-21.76,11.29-15.14-.28.27.49.89.09,1.03-.25.08-.69-.15-1.14.58-.32.51.58,1.26-.3,1.42l-1.48.26,1.93-3.85c.54-1.09.15-2.4-.76-2.62-3.3-.79-4.86,10.32-16.21,10.62ZM99.04,299.48l-2.33-4.68-3.47.03c-.04,6.06,3.08,10.63,7.43,14.01,4.93,3.83,10.3,5.14,16.54,4.87,10.18-.44,19.57-6.79,21.53-16.96,1.57-8.14-2.54-15.62-9.31-19.96-7.16-4.59-16.77-5.53-20.09-10.13-1.83-2.53-1.29-5.65.09-8.51,1.08-2.23,3.67-3.76,6.68-4.4,8.68-1.84,13.61,5.15,15,4.52.46-.2,1.46-.79,1.17-1.39l-1.53-3.17c2.36.38,3.63,3.38,4.81,1.76,1.42-1.94-9.35-13.75-24.73-9.15-8.64,2.58-13.65,10.74-11.83,19.53.96,4.64,3.52,8.84,8.3,10.67l12.22,4.68c6.12,2.34,10.3,8.86,8.33,15.46-1.45,4.86-6.35,7.18-11.25,7.51-5.75.39-12.14-2.11-13.82-7.94-.66-2.3-.6-5.38-4.17-5.51-.75,4.05,1.16,6.89.42,8.75ZM103.94,315l-7.75,6.79c.97.96,1.64.88,2.27.52-.28.16,5.09-5.27,5.48-7.31Z"/>
|
||||||
|
<path class="cls-3" d="M210.63,274c-.34,2.98,4.2,2.59,4.27,3.76l2.51,42.37c.1,1.67-2.35,3.33-3.58,3.16l.11-2.9c-5.22.96-10.62.62-15.97,0l2.36-4.83c.6-2.77.86-6.32,3.11-8.25l3.61-3.1c1.42-1.22,1.82-1.99,1.63-3.75-.11-1.05-.58-3.25-2.29-2.91-1.04.2-1.88,1.81-3.19,1.23l2.34-5.89,5.94-12.1c.18-.36.42-1.36.11-1.49s-.97-.45-1.55-.45l-30.61.32c-2.64.03-5.17.59-8.11-.31l24.66-3.99,13.32-2.67c.39-.08,0,1,.21,1.23.25.29.71.61,1.1.57Z"/>
|
||||||
|
<path class="cls-1" d="M213.84,323.29c.69.99.45,1.15-.19,1.78l-17.39,17.12c-3.16,3.11-6.44,5.57-9.4,9.05-.38.45-.97.07-1.39-.06l-.4-1.88c1.94-6.68,3.23-13.66,2.17-20.91-.22-1.52-.28-2.52.82-3.51.9-.81,1.78-.11,3.06-.13l14.27-.23c3.03-.05,5.64-1.6,8.45-1.22Z"/>
|
||||||
|
<path class="cls-3" d="M69.29,278.12c.52.08.69-.14.92-.03.63.32.97-.16.76-1.25,3.87,1.96,7.55,1.31,11.44,1.87l11.54-.04,16.48.56c.38.01.66.65.78.81.93,1.22-3.8.82-5.59,1.14l-30.23,5.39c-.61.87-1.15.42-1.48.62-.71.43-1.96.04-2.07-.83l.8-.58.55-.55-.55.55.55-.55c.13-.13.21-.35.38-.39.47-.12.74-1.1.12-1.45-.29-.17-.73,0-.96-1.07-.92-1.33-3.2-2.39-3.45-4.18Z"/>
|
||||||
|
<path class="cls-3" d="M145.75,353.91l-.55-.2c-1.16-.42-2.27-1.18-3.54-1.9-1.2-.68-1.89,1.02-2.51.94-1.01-.26-2.05-.49-3.6.38-.38-4.26,1.1-7.9,1.64-11.98l4.19-32.02c1.97.15.43.91.61,1.17.33.47.11,1.42.12,1.78l.38,23.49c.03,2.02.13,4.23.68,6.05l3.02,10.07c-1.11.7-.61,1.67-1,2.02l.55.2Z"/>
|
||||||
|
<path class="cls-2" d="M181.3,203.36c.18.94.09,1.76.6,2.46-2.6.64-2.24,3-3.09,4.69l-10.81,21.63c-1.69,3.38-2.35,7.1-5.27,10.02l2.13-9.75c1.71-7.83,3.31-13.12,3.15-21.63l2.15-.91c.09.12.09.4.12.6.19,1.19.46,2.37,1.32,3.3.37-.75,1.52-.99,1.94-1.77l3.72-6.88c.54-1,.67-1.36,1.64-1.89,1.02-.56,1.31-.09,2.4.14Z"/>
|
||||||
|
<path class="cls-3" d="M147.94,207.55l.57-.22-3.92,47.24c-.34-.16-.67-.01-.92-.02l.2-8.37-.8-17.66-1.11-13.13c-.15-1.82-2.26-5.09-1.72-9.19.2.02.38.14.56.21,2.24.82,4.78.99,7.14,1.13Z"/>
|
||||||
|
<path class="cls-2" d="M214.65,239.1c-.45,2.26-2.11,3.94-3.91,5.65-.84.16-1.05.39-1.13.79-.15.82.08,1.71,1.03,1.66.76-.04,1.5.2,2.23.54-.16.62.22,1.57-.21,2.2l-11.9,1.19-12.97,3.01c1.1-2.81,3.92-3.76,6.12-5.03l6.56-3.79c3.88-2.24,7.82-4.22,11.61-6.32l2.58.1Z"/>
|
||||||
|
<path class="cls-4" d="M99.04,299.48c.73-1.86-1.18-4.7-.42-8.75,3.57.13,3.51,3.2,4.17,5.51,1.68,5.83,8.07,8.33,13.82,7.94,4.9-.33,9.79-2.65,11.25-7.51,1.97-6.6-2.21-13.11-8.33-15.46l-12.22-4.68c-4.78-1.83-7.34-6.03-8.3-10.67-1.82-8.79,3.19-16.95,11.83-19.53,15.38-4.6,26.15,7.2,24.73,9.15-1.18,1.62-2.45-1.38-4.81-1.76l1.53,3.17c.29.6-.71,1.18-1.17,1.39-1.39.62-6.32-6.36-15-4.52-3.02.64-5.61,2.16-6.68,4.4-1.38,2.86-1.93,5.98-.09,8.51,3.33,4.59,12.93,5.53,20.09,10.13,6.77,4.34,10.89,11.82,9.31,19.96-1.96,10.18-11.36,16.52-21.53,16.96-6.25.27-11.61-1.04-16.54-4.87-4.35-3.38-7.47-7.95-7.43-14.01l3.47-.03,2.33,4.68Z"/>
|
||||||
|
<path class="cls-6" d="M168.83,303.16c11.35-.3,12.91-11.42,16.21-10.62.9.22,1.3,1.53.76,2.62l-1.93,3.85,1.48-.26c.88-.16-.02-.91.3-1.42.46-.73.9-.5,1.14-.58.4-.14-.37-.76-.09-1.03,7.01-6.62,2.58,10.66-11.29,15.14-7.97,2.58-16.35,1.27-22.41-3.63-4.76-3.85-6.8-9.53-8.36-15.03-2.51-8.9-.86-18.09,2.65-26.66,3.1-7.56,7.25-14.49,14.58-18.6,4.98-2.79,10.38-2.41,15.84.29,8.47,4.18,10.13,15.07,6.67,13.59-.52-.22-.52-1.27-.95-1.95-.16-.64-.27-.62-.26-.77.02-.36-.52-.95-.9-.33-.12.2.09,1.95.56,2.97-.19-.41-1.19,3.23-1.06,3.05-2.65,3.53-2.14-5.98-7.33-8.34-10.46-4.76-19.07,10.15-20.19,20.69-1.4,5.23-.84,10.52.88,15.55,1.55,6.36,6.74,11.66,13.68,11.47Z"/>
|
||||||
|
<path class="cls-2" d="M197.98,320.39c5.36.62,10.75.96,15.97,0l-.11,2.9c-2.81-.38-5.42,1.17-8.45,1.22l-14.27.23c-1.27.02-2.16-.67-3.06.13-1.1.99-1.04,1.99-.82,3.51,1.06,7.25-.23,14.24-2.17,20.91l-2.38.32c1.98-9.79-2.64-27.16-1.51-29.66.29-.65,1.43-1.35,2.59-1.21l14.21,1.65Z"/>
|
||||||
|
<path class="cls-3" d="M103.94,315c-.39,2.04-5.76,7.47-5.48,7.31-.63.36-1.3.44-2.27-.52l7.75-6.79Z"/>
|
||||||
|
<path class="cls-5" d="M210.64,247.2c.76-.04,1.5.2,2.23.54,1.11.52,3.9,1.16,3.71,3.1l-1.82,18.71c-1.5,1.84-3.42,2.56-4.11,4.45-.34,2.98,4.2,2.59,4.27,3.76l2.51,42.37c.1,1.67-2.35,3.33-3.58,3.16-2.81-.38-5.42,1.17-8.45,1.22l-14.27.23c-1.27.02-2.16-.67-3.06.13-1.1.99-1.04,1.99-.82,3.51,1.06,7.25-.23,14.24-2.17,20.91l.4,1.88c-.02,1.11-.76,2.05-1.49,2.86-.9,1-2.37.8-3.88.8l-24.17.07c-3.64.01-6.98.15-10.19-1.01l-.55-.2c-1.16-.42-2.27-1.18-3.54-1.9-1.2-.68-1.89,1.02-2.51.94-1.06.63-1.9,2.74-3.63,3-8.84,1.34-17.96,1.85-26.77.44l-13.88-3.27c-1.56-1.74-2.19-3.54-3.98-4.71l-6.41,7.6c-5.24-.33-10.27-1.31-15.49-2.21-2.15-4.26-.12-10.16-1.62-14.2-1.64-4.39-1.98-8.67-1.04-13.21,1.9-9.16,1.37-17.8.49-27.02-.43-4.52,3.2-9.56,5.02-12.83l.8-.58.55-.55c.13-.13.21-.35.38-.39"/>
|
||||||
|
<path class="cls-5" d="M172.5,214.73l-.9-.97c-.85-.92-1.12-2.1-1.32-3.3-.03-.2-.03-.48-.12-.6-.7-.95-.55-3.83-2.12-4.44l-19.53,1.91-.57.22c-2.36-.14-4.9-.31-7.14-1.13-.19-.07-.37-.18-.56-.21l-8.59-1.05-16.36,1.2c-8.38.62-16.38,1.23-24.8.67-3.03,1.4-6.08,2.17-9.33,1.63-7.26-2.24-12.54-4.74-12.92.43l-.97,13.21c-.36,4.92-.07,9.67,2.39,14.17-1.22,3.36.19,5.9,2.9,7.27"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.7 KiB |
@@ -1,10 +1,28 @@
|
|||||||
var RoleSelect = (function () {
|
var RoleSelect = (function () {
|
||||||
|
// Set to true by handleTurnChanged so that a WS turn_changed that races
|
||||||
|
// ahead of the fetch response doesn't get overridden by Tray.open().
|
||||||
|
var _turnChangedBeforeFetch = false;
|
||||||
|
|
||||||
|
// Set to true while placeCard animation is running. handleTurnChanged
|
||||||
|
// defers its work until the animation completes.
|
||||||
|
var _animationPending = false;
|
||||||
|
var _pendingTurnChange = null;
|
||||||
|
|
||||||
|
// Delay before the tray animation begins (ms). Gives the gamer a moment
|
||||||
|
// to see their pick confirmed before the tray slides in. Set to 0 by
|
||||||
|
// _testReset() so Jasmine tests don't need jasmine.clock().
|
||||||
|
var _placeCardDelay = 3000;
|
||||||
|
|
||||||
|
// Delay after the tray closes before advancing to the next turn (ms).
|
||||||
|
// Gives the gamer a moment to see their confirmed seat before the turn moves.
|
||||||
|
var _postTrayDelay = 3000;
|
||||||
|
|
||||||
var ROLES = [
|
var ROLES = [
|
||||||
{ code: "PC", name: "Player", element: "Fire" },
|
|
||||||
{ code: "BC", name: "Builder", element: "Stone" },
|
|
||||||
{ code: "SC", name: "Shepherd", element: "Air" },
|
{ code: "SC", name: "Shepherd", element: "Air" },
|
||||||
{ code: "AC", name: "Alchemist", element: "Water" },
|
{ code: "PC", name: "Player", element: "Fire" },
|
||||||
{ code: "NC", name: "Narrator", element: "Time" },
|
{ code: "NC", name: "Narrator", element: "Time" },
|
||||||
|
{ code: "AC", name: "Alchemist", element: "Water" },
|
||||||
|
{ code: "BC", name: "Builder", element: "Stone" },
|
||||||
{ code: "EC", name: "Economist", element: "Space" },
|
{ code: "EC", name: "Economist", element: "Space" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -23,16 +41,13 @@ var RoleSelect = (function () {
|
|||||||
if (backdrop) backdrop.remove();
|
if (backdrop) backdrop.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectRole(roleCode, cardEl) {
|
function selectRole(roleCode) {
|
||||||
var invCard = cardEl.cloneNode(true);
|
_turnChangedBeforeFetch = false; // fresh selection, reset the race flag
|
||||||
invCard.classList.add("flipped");
|
|
||||||
// strip old event listeners from the clone by replacing with a clean copy
|
|
||||||
var clean = invCard.cloneNode(true);
|
|
||||||
|
|
||||||
closeFan();
|
closeFan();
|
||||||
|
|
||||||
var invSlot = document.getElementById("id_inv_role_card");
|
// Show the tray handle — gamer confirmed a pick, tray animation about to run
|
||||||
if (invSlot) invSlot.appendChild(clean);
|
var trayWrap = document.getElementById("id_tray_wrap");
|
||||||
|
if (trayWrap) trayWrap.classList.remove("role-select-phase");
|
||||||
|
|
||||||
// Immediately lock the stack — do not wait for WS turn_changed
|
// Immediately lock the stack — do not wait for WS turn_changed
|
||||||
var stack = document.querySelector(".card-stack[data-starter-roles]");
|
var stack = document.querySelector(".card-stack[data-starter-roles]");
|
||||||
@@ -43,8 +58,28 @@ var RoleSelect = (function () {
|
|||||||
stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode;
|
stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark seat as actively being claimed (glow state) and swap ban → check immediately
|
||||||
|
var activePos = document.querySelector('.table-seat[data-role="' + roleCode + '"]');
|
||||||
|
if (activePos) {
|
||||||
|
activePos.classList.add('active');
|
||||||
|
var ban = activePos.querySelector('.fa-ban');
|
||||||
|
if (ban) { ban.classList.remove('fa-ban'); ban.classList.add('fa-circle-check'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediately fade out the gate-slot circle for the current turn's slot
|
||||||
|
var activeSlot = stack ? stack.dataset.activeSlot : null;
|
||||||
|
if (activeSlot) {
|
||||||
|
var slotCircle = document.querySelector('.gate-slot[data-slot="' + activeSlot + '"]');
|
||||||
|
if (slotCircle) slotCircle.classList.add('role-assigned');
|
||||||
|
}
|
||||||
|
|
||||||
var url = getSelectRoleUrl();
|
var url = getSelectRoleUrl();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
|
|
||||||
|
// Block handleTurnChanged immediately — WS turn_changed can arrive while
|
||||||
|
// the fetch is in-flight and must be deferred until our animation completes.
|
||||||
|
_animationPending = true;
|
||||||
|
|
||||||
fetch(url, {
|
fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -55,12 +90,40 @@ var RoleSelect = (function () {
|
|||||||
}).then(function (response) {
|
}).then(function (response) {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Server rejected (role already taken) — undo optimistic update
|
// Server rejected (role already taken) — undo optimistic update
|
||||||
if (invSlot && invSlot.contains(clean)) invSlot.removeChild(clean);
|
_animationPending = false;
|
||||||
if (stack) {
|
if (stack) {
|
||||||
stack.dataset.starterRoles = stack.dataset.starterRoles
|
stack.dataset.starterRoles = stack.dataset.starterRoles
|
||||||
.split(",").filter(function (r) { return r.trim() !== roleCode; }).join(",");
|
.split(",").filter(function (r) { return r.trim() !== roleCode; }).join(",");
|
||||||
}
|
}
|
||||||
openFan();
|
openFan();
|
||||||
|
} else {
|
||||||
|
// Animate the role card into the tray: open, arc-in, force-close.
|
||||||
|
// Any turn_changed that arrived while the fetch was in-flight is
|
||||||
|
// queued in _pendingTurnChange and will run after onComplete.
|
||||||
|
if (typeof Tray !== "undefined") {
|
||||||
|
setTimeout(function () {
|
||||||
|
Tray.placeCard(roleCode, function () {
|
||||||
|
// Swap ban → check, clear glow, mark seat as confirmed
|
||||||
|
var seatedPos = document.querySelector('.table-seat[data-role="' + roleCode + '"]');
|
||||||
|
if (seatedPos) {
|
||||||
|
seatedPos.classList.remove('active');
|
||||||
|
seatedPos.classList.add('role-confirmed');
|
||||||
|
}
|
||||||
|
// Hold _animationPending through the post-tray pause so any
|
||||||
|
// turn_changed WS event that arrives now is still deferred.
|
||||||
|
setTimeout(function () {
|
||||||
|
_animationPending = false;
|
||||||
|
if (_pendingTurnChange) {
|
||||||
|
var ev = _pendingTurnChange;
|
||||||
|
_pendingTurnChange = null;
|
||||||
|
handleTurnChanged(ev);
|
||||||
|
}
|
||||||
|
}, _postTrayDelay);
|
||||||
|
});
|
||||||
|
}, _placeCardDelay);
|
||||||
|
} else {
|
||||||
|
_animationPending = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -91,7 +154,7 @@ var RoleSelect = (function () {
|
|||||||
|
|
||||||
var back = document.createElement("div");
|
var back = document.createElement("div");
|
||||||
back.className = "card-back";
|
back.className = "card-back";
|
||||||
back.textContent = "?";
|
back.textContent = "ROLE";
|
||||||
|
|
||||||
var front = document.createElement("div");
|
var front = document.createElement("div");
|
||||||
front.className = "card-front";
|
front.className = "card-front";
|
||||||
@@ -114,15 +177,16 @@ var RoleSelect = (function () {
|
|||||||
card.classList.add("guard-active");
|
card.classList.add("guard-active");
|
||||||
window.showGuard(
|
window.showGuard(
|
||||||
card,
|
card,
|
||||||
"Start round 1 as<br>" + role.name + " (" + role.code + ") …?",
|
"Start round 1<br>as " + role.name + " (" + role.code + ") …?",
|
||||||
function () { // confirm
|
function () { // confirm
|
||||||
card.classList.remove("guard-active");
|
card.classList.remove("guard-active");
|
||||||
selectRole(role.code, card);
|
selectRole(role.code);
|
||||||
},
|
},
|
||||||
function () { // dismiss (NVM / outside click)
|
function () { // dismiss (NVM / outside click)
|
||||||
card.classList.remove("guard-active");
|
card.classList.remove("guard-active");
|
||||||
card.classList.remove("flipped");
|
card.classList.remove("flipped");
|
||||||
}
|
},
|
||||||
|
{ invertY: true } // modal grid: tooltip flies away from centre (upper→above, lower→below)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,14 +206,65 @@ var RoleSelect = (function () {
|
|||||||
|
|
||||||
var _reload = function () { window.location.reload(); };
|
var _reload = function () { window.location.reload(); };
|
||||||
|
|
||||||
function handleRolesRevealed() {
|
function handleAllRolesFilled() {
|
||||||
|
var wrap = document.getElementById('id_pick_sigs_wrap');
|
||||||
|
if (wrap) wrap.style.display = '';
|
||||||
|
var stack = document.querySelector('.card-stack');
|
||||||
|
if (stack) stack.remove();
|
||||||
|
var trayWrap = document.getElementById('id_tray_wrap');
|
||||||
|
if (trayWrap) trayWrap.classList.remove('role-select-phase');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSigSelectStarted() {
|
||||||
_reload();
|
_reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTurnChanged(event) {
|
function handleTurnChanged(event) {
|
||||||
|
// If a placeCard animation is running, defer until it completes.
|
||||||
|
if (_animationPending) {
|
||||||
|
_pendingTurnChange = event;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var active = String(event.detail.active_slot);
|
var active = String(event.detail.active_slot);
|
||||||
var invSlot = document.getElementById("id_inv_role_card");
|
|
||||||
if (invSlot) invSlot.innerHTML = "";
|
// Force-close tray instantly so it never obscures the next player's card-stack.
|
||||||
|
// Also set the race flag so the fetch .then() doesn't re-open if it arrives late.
|
||||||
|
_turnChangedBeforeFetch = true;
|
||||||
|
if (typeof Tray !== "undefined") Tray.forceClose();
|
||||||
|
|
||||||
|
// Hide tray handle until the next player confirms their pick
|
||||||
|
var trayWrap = document.getElementById("id_tray_wrap");
|
||||||
|
if (trayWrap) trayWrap.classList.add("role-select-phase");
|
||||||
|
|
||||||
|
// Clear any stale .active glow from hex seats
|
||||||
|
document.querySelectorAll('.table-seat.active').forEach(function (p) {
|
||||||
|
p.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync seat icons from starter_roles so state persists without a reload
|
||||||
|
if (event.detail.starter_roles) {
|
||||||
|
var assignedRoles = event.detail.starter_roles;
|
||||||
|
document.querySelectorAll(".table-seat").forEach(function (seat) {
|
||||||
|
var role = seat.dataset.role;
|
||||||
|
if (assignedRoles.indexOf(role) !== -1) {
|
||||||
|
seat.classList.add("role-confirmed");
|
||||||
|
var ban = seat.querySelector(".fa-ban");
|
||||||
|
if (ban) { ban.classList.remove("fa-ban"); ban.classList.add("fa-circle-check"); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Hide slot circles in turn order: slots 1..N done when N roles assigned
|
||||||
|
var assignedCount = assignedRoles.length;
|
||||||
|
document.querySelectorAll(".gate-slot[data-slot]").forEach(function (circle) {
|
||||||
|
if (parseInt(circle.dataset.slot, 10) <= assignedCount) {
|
||||||
|
circle.classList.add("role-assigned");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update active slot on the card stack so selectRole() can read it
|
||||||
|
var stack = document.querySelector(".card-stack[data-user-slots]");
|
||||||
|
if (stack) stack.dataset.activeSlot = active;
|
||||||
|
|
||||||
var stack = document.querySelector(".card-stack[data-user-slots]");
|
var stack = document.querySelector(".card-stack[data-user-slots]");
|
||||||
if (stack) {
|
if (stack) {
|
||||||
@@ -178,17 +293,16 @@ var RoleSelect = (function () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move .active to the newly active seat
|
// Clear any stale seat glow (JS-only; glow is only during tray animation)
|
||||||
document.querySelectorAll(".table-seat.active").forEach(function (s) {
|
document.querySelectorAll(".table-seat.active").forEach(function (s) {
|
||||||
s.classList.remove("active");
|
s.classList.remove("active");
|
||||||
});
|
});
|
||||||
var activeSeat = document.querySelector(".table-seat[data-slot='" + active + "']");
|
|
||||||
if (activeSeat) activeSeat.classList.add("active");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener("room:role_select_start", init);
|
window.addEventListener("room:role_select_start", init);
|
||||||
window.addEventListener("room:turn_changed", handleTurnChanged);
|
window.addEventListener("room:turn_changed", handleTurnChanged);
|
||||||
window.addEventListener("room:roles_revealed", handleRolesRevealed);
|
window.addEventListener("room:all_roles_filled", handleAllRolesFilled);
|
||||||
|
window.addEventListener("room:sig_select_started", handleSigSelectStarted);
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", init);
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
@@ -197,8 +311,15 @@ var RoleSelect = (function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
openFan: openFan,
|
openFan: openFan,
|
||||||
closeFan: closeFan,
|
closeFan: closeFan,
|
||||||
setReload: function (fn) { _reload = fn; },
|
setReload: function (fn) { _reload = fn; },
|
||||||
|
// Testing hook — resets animation-pause state between Jasmine specs
|
||||||
|
_testReset: function () {
|
||||||
|
_animationPending = false;
|
||||||
|
_pendingTurnChange = null;
|
||||||
|
_placeCardDelay = 0;
|
||||||
|
_postTrayDelay = 0;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}());
|
}());
|
||||||
|
|||||||
@@ -1,3 +1,111 @@
|
|||||||
|
(function () {
|
||||||
|
var SCENE_W = 360, SCENE_H = 300;
|
||||||
|
|
||||||
|
function scaleTable() {
|
||||||
|
var scene = document.querySelector('.room-table-scene');
|
||||||
|
var container = document.getElementById('id_game_table');
|
||||||
|
if (!scene || !container) return;
|
||||||
|
var w = container.clientWidth, h = container.clientHeight;
|
||||||
|
if (!w || !h) return;
|
||||||
|
var scale = Math.min(w / SCENE_W, h / SCENE_H);
|
||||||
|
scene.style.transform = 'scale(' + scale + ')';
|
||||||
|
document.documentElement.style.setProperty('--table-scale', scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', scaleTable);
|
||||||
|
} else {
|
||||||
|
scaleTable();
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', scaleTable);
|
||||||
|
window.addEventListener('resize:end', scaleTable);
|
||||||
|
}());
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
// Size the sig-select overlay so the card grid clears the tray handle
|
||||||
|
// (portrait: right strip 48px; landscape: bottom strip 48px) and any
|
||||||
|
// fixed gear/kit buttons that protrude further into the viewport.
|
||||||
|
// Mirrors the scaleTable() pattern — runs on load (after tray.js has
|
||||||
|
// positioned the tray) and on every resize.
|
||||||
|
function sizeSigModal() {
|
||||||
|
var overlay = document.querySelector('.sig-overlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
var vw = window.innerWidth;
|
||||||
|
var vh = window.innerHeight;
|
||||||
|
var rightInset = 0;
|
||||||
|
var bottomInset = 0;
|
||||||
|
|
||||||
|
var isLandscape = vw > vh;
|
||||||
|
|
||||||
|
// Tray handle: portrait → vertical strip on right; landscape → tray is easily
|
||||||
|
// dismissed, so skip the bottomInset calculation (would over-shrink the modal).
|
||||||
|
var trayHandle = document.getElementById('id_tray_handle');
|
||||||
|
if (trayHandle && !isLandscape) {
|
||||||
|
var hr = trayHandle.getBoundingClientRect();
|
||||||
|
if (hr.width < hr.height) {
|
||||||
|
// Portrait: handle strips the right edge
|
||||||
|
rightInset = vw - hr.left;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gear / kit buttons: update right inset if near right edge.
|
||||||
|
// In landscape they sit at bottom-right but are also dismissible — skip bottomInset.
|
||||||
|
document.querySelectorAll('.room-page > .gear-btn').forEach(function (btn) {
|
||||||
|
var br = btn.getBoundingClientRect();
|
||||||
|
if (br.right > vw - 30) {
|
||||||
|
rightInset = Math.max(rightInset, vw - br.left);
|
||||||
|
}
|
||||||
|
if (!isLandscape && br.bottom > vh - 30) {
|
||||||
|
bottomInset = Math.max(bottomInset, vh - br.top);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Landscape: right clears gear/kit buttons; bottom is fixed 60px for the
|
||||||
|
// kit-bag handle strip — tray is ignored so the stage has room to breathe.
|
||||||
|
// At ≥1800px the right sidebar doubles to 8rem so clear 128px.
|
||||||
|
if (isLandscape) {
|
||||||
|
var xlBreak = vw >= 1800;
|
||||||
|
rightInset = Math.max(rightInset, xlBreak ? 128 : 64);
|
||||||
|
bottomInset = 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.style.paddingRight = rightInset + 'px';
|
||||||
|
overlay.style.paddingBottom = bottomInset + 'px';
|
||||||
|
|
||||||
|
// Stage card: smaller of 40% stage width OR (80% stage height × 5/8 aspect).
|
||||||
|
// libsass can't handle cqw/cqh inside min(), so we compute it here.
|
||||||
|
var stageEl = overlay.querySelector('.sig-stage');
|
||||||
|
if (stageEl) {
|
||||||
|
var sw = stageEl.offsetWidth - 24; // subtract padding (0.75rem × 2)
|
||||||
|
var sh = stageEl.offsetHeight - 24;
|
||||||
|
if (sw > 0 && sh > 0) {
|
||||||
|
// Clamp between 90px (never tiny in landscape) and 160px (never
|
||||||
|
// dominant on very wide/tall viewports). In portrait, skip the
|
||||||
|
// floor so small modals still scale down naturally.
|
||||||
|
var cardW = Math.min(sw * 0.4, sh * 0.8 * 5 / 8, 160);
|
||||||
|
if (isLandscape) { cardW = Math.max(cardW, 90); }
|
||||||
|
overlay.style.setProperty('--sig-card-w', cardW + 'px');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', sizeSigModal);
|
||||||
|
window.addEventListener('resize', sizeSigModal);
|
||||||
|
window.addEventListener('resize:end', sizeSigModal);
|
||||||
|
}());
|
||||||
|
|
||||||
|
// Dispatch a custom 'resize:end' event 500 ms after the last 'resize' fires.
|
||||||
|
// scaleTable, sizeSigModal, and Tray._reposition all subscribe to it so they
|
||||||
|
// re-measure with settled viewport dimensions after rapid resize sequences.
|
||||||
|
(function () {
|
||||||
|
var t;
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
clearTimeout(t);
|
||||||
|
t = setTimeout(function () { window.dispatchEvent(new Event('resize:end')); }, 500);
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
const roomPage = document.querySelector('.room-page');
|
const roomPage = document.querySelector('.room-page');
|
||||||
if (!roomPage) return;
|
if (!roomPage) return;
|
||||||
@@ -5,6 +113,7 @@
|
|||||||
const roomId = roomPage.dataset.roomId;
|
const roomId = roomPage.dataset.roomId;
|
||||||
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
const ws = new WebSocket(`${wsScheme}://${window.location.host}/ws/room/${roomId}/`);
|
const ws = new WebSocket(`${wsScheme}://${window.location.host}/ws/room/${roomId}/`);
|
||||||
|
window._roomSocket = ws; // exposed for sig-select.js hover broadcast
|
||||||
|
|
||||||
ws.onmessage = function (event) {
|
ws.onmessage = function (event) {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
|||||||
477
src/apps/epic/static/apps/epic/sig-select.js
Normal file
477
src/apps/epic/static/apps/epic/sig-select.js
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
var SigSelect = (function () {
|
||||||
|
// Polarity → three roles in fixed left/mid/right cursor order
|
||||||
|
var POLARITY_ROLES = {
|
||||||
|
levity: ['PC', 'NC', 'SC'],
|
||||||
|
gravity: ['BC', 'EC', 'AC'],
|
||||||
|
};
|
||||||
|
|
||||||
|
var overlay, deckGrid, stage, stageCard, statBlock;
|
||||||
|
var cautionEl, cautionEffect, cautionPrev, cautionNext, cautionIndexEl;
|
||||||
|
var _flipBtn, _cautionBtn, _flipOrigLabel, _cautionOrigLabel;
|
||||||
|
var reserveUrl, userRole, userPolarity;
|
||||||
|
|
||||||
|
var _cautionData = [];
|
||||||
|
var _cautionIdx = 0;
|
||||||
|
|
||||||
|
var _focusedCardEl = null; // card currently shown in stage
|
||||||
|
var _reservedCardId = null; // card with active reservation
|
||||||
|
var _stageFrozen = false; // true after OK — stage locks on reserved card
|
||||||
|
var _requestInFlight = false;
|
||||||
|
|
||||||
|
var _floatingCursors = {}; // key: cardId+posClass → portal <i> element (hover)
|
||||||
|
var _reservedFloats = {}; // key: role → portal <i> element (thumbs-up, frozen)
|
||||||
|
var _cursorPortal = null;
|
||||||
|
|
||||||
|
function getCsrf() {
|
||||||
|
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stage ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _populateKeywordList(listEl, csv) {
|
||||||
|
var keywords = csv ? csv.split(',').filter(Boolean) : [];
|
||||||
|
listEl.innerHTML = keywords.map(function (k) {
|
||||||
|
return '<li>' + k.trim() + '</li>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Caution tooltip ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderCaution() {
|
||||||
|
if (_cautionData.length === 0) {
|
||||||
|
cautionEffect.innerHTML = '<em>Rival interactions pending.</em>';
|
||||||
|
cautionPrev.disabled = true;
|
||||||
|
cautionNext.disabled = true;
|
||||||
|
cautionIndexEl.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cautionEffect.innerHTML = _cautionData[_cautionIdx];
|
||||||
|
cautionPrev.disabled = (_cautionData.length <= 1);
|
||||||
|
cautionNext.disabled = (_cautionData.length <= 1);
|
||||||
|
cautionIndexEl.textContent = _cautionData.length > 1
|
||||||
|
? (_cautionIdx + 1) + ' / ' + _cautionData.length
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _openCaution() {
|
||||||
|
if (!_focusedCardEl) return;
|
||||||
|
try {
|
||||||
|
_cautionData = JSON.parse(_focusedCardEl.dataset.cautions || '[]');
|
||||||
|
} catch (e) {
|
||||||
|
_cautionData = [];
|
||||||
|
}
|
||||||
|
_cautionIdx = 0;
|
||||||
|
_renderCaution();
|
||||||
|
_flipBtn.classList.add('btn-disabled');
|
||||||
|
_cautionBtn.classList.add('btn-disabled');
|
||||||
|
_flipBtn.textContent = '\u00D7';
|
||||||
|
_cautionBtn.textContent = '\u00D7';
|
||||||
|
stage.classList.add('sig-caution-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _closeCaution() {
|
||||||
|
stage.classList.remove('sig-caution-open');
|
||||||
|
if (_flipBtn) {
|
||||||
|
_flipBtn.classList.remove('btn-disabled');
|
||||||
|
_cautionBtn.classList.remove('btn-disabled');
|
||||||
|
_flipBtn.textContent = _flipOrigLabel;
|
||||||
|
_cautionBtn.textContent = _cautionOrigLabel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStage(cardEl) {
|
||||||
|
if (_stageFrozen) return;
|
||||||
|
_closeCaution();
|
||||||
|
if (!cardEl) {
|
||||||
|
stageCard.style.display = 'none';
|
||||||
|
stage.classList.remove('sig-stage--active');
|
||||||
|
_focusedCardEl = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_focusedCardEl = cardEl;
|
||||||
|
|
||||||
|
var rank = cardEl.dataset.cornerRank || '';
|
||||||
|
var icon = cardEl.dataset.suitIcon || '';
|
||||||
|
var group = cardEl.dataset.nameGroup || '';
|
||||||
|
var title = cardEl.dataset.nameTitle || '';
|
||||||
|
var arcana= cardEl.dataset.arcana || '';
|
||||||
|
var corr = cardEl.dataset.correspondence || '';
|
||||||
|
|
||||||
|
stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) { el.textContent = rank; });
|
||||||
|
stageCard.querySelectorAll('.stage-suit-icon').forEach(function (el) {
|
||||||
|
if (icon) {
|
||||||
|
el.className = 'fa-solid ' + icon + ' stage-suit-icon';
|
||||||
|
el.style.display = '';
|
||||||
|
} else {
|
||||||
|
el.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stageCard.querySelector('.fan-card-name-group').textContent = group;
|
||||||
|
stageCard.querySelector('.fan-card-arcana').textContent = arcana;
|
||||||
|
stageCard.querySelector('.fan-card-correspondence').textContent = ''; // shown in game-kit only
|
||||||
|
|
||||||
|
var qualifier = userPolarity === 'levity' ? 'Leavened' : 'Graven';
|
||||||
|
var isMajor = arcana.toLowerCase().indexOf('major') !== -1;
|
||||||
|
// Major arcana: qualifier sits below the title — append comma so it reads as a subtitle.
|
||||||
|
stageCard.querySelector('.fan-card-name').textContent = isMajor ? title + ',' : title;
|
||||||
|
stageCard.querySelector('.sig-qualifier-above').textContent = isMajor ? '' : qualifier;
|
||||||
|
stageCard.querySelector('.sig-qualifier-below').textContent = isMajor ? qualifier : '';
|
||||||
|
|
||||||
|
// Populate stat block keyword faces and reset to upright
|
||||||
|
statBlock.classList.remove('is-reversed');
|
||||||
|
_populateKeywordList(
|
||||||
|
statBlock.querySelector('#id_stat_keywords_upright'),
|
||||||
|
cardEl.dataset.keywordsUpright
|
||||||
|
);
|
||||||
|
_populateKeywordList(
|
||||||
|
statBlock.querySelector('#id_stat_keywords_reversed'),
|
||||||
|
cardEl.dataset.keywordsReversed
|
||||||
|
);
|
||||||
|
|
||||||
|
stageCard.style.display = '';
|
||||||
|
stage.classList.add('sig-stage--active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Focus a card (click/tap) — shows OK overlay on the card ──────────
|
||||||
|
|
||||||
|
function focusCard(cardEl) {
|
||||||
|
deckGrid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) {
|
||||||
|
if (c !== cardEl) c.classList.remove('sig-focused');
|
||||||
|
});
|
||||||
|
cardEl.classList.add('sig-focused');
|
||||||
|
updateStage(cardEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hover events ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function onCardEnter(e) {
|
||||||
|
var card = e.currentTarget;
|
||||||
|
if (!_stageFrozen) updateStage(card);
|
||||||
|
sendHover(card.dataset.cardId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCardLeave(e) {
|
||||||
|
if (!_stageFrozen) updateStage(null);
|
||||||
|
sendHover(e.currentTarget.dataset.cardId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reserve / release ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function doReserve(cardEl) {
|
||||||
|
if (_requestInFlight) return;
|
||||||
|
var cardId = cardEl.dataset.cardId;
|
||||||
|
_requestInFlight = true;
|
||||||
|
fetch(reserveUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||||
|
body: 'action=reserve&card_id=' + encodeURIComponent(cardId),
|
||||||
|
}).then(function (res) {
|
||||||
|
_requestInFlight = false;
|
||||||
|
if (res.ok) applyReservation(cardId, userRole, true);
|
||||||
|
}).catch(function () { _requestInFlight = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function doRelease() {
|
||||||
|
if (_requestInFlight || !_reservedCardId) return;
|
||||||
|
var cardId = _reservedCardId;
|
||||||
|
_requestInFlight = true;
|
||||||
|
fetch(reserveUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||||
|
body: 'action=release&card_id=' + encodeURIComponent(cardId),
|
||||||
|
}).then(function (res) {
|
||||||
|
_requestInFlight = false;
|
||||||
|
if (res.ok) applyReservation(cardId, userRole, false);
|
||||||
|
}).catch(function () { _requestInFlight = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apply reservation state (local + from WS) ─────────────────────────
|
||||||
|
|
||||||
|
function _placeReservedFloat(cardId, cardEl, role) {
|
||||||
|
// Remove any pre-existing reserved float for this role (e.g. page-load replay)
|
||||||
|
if (_reservedFloats[role]) { _reservedFloats[role].remove(); }
|
||||||
|
|
||||||
|
// Retire ALL hover floats for this role — may be on a different card than reserved
|
||||||
|
var roles = POLARITY_ROLES[userPolarity] || [];
|
||||||
|
var idx = roles.indexOf(role);
|
||||||
|
var posClass = ['--left', '--mid', '--right'][idx] || '--left';
|
||||||
|
Object.keys(_floatingCursors).forEach(function (key) {
|
||||||
|
if (key.slice(-posClass.length) === posClass) {
|
||||||
|
_floatingCursors[key].remove();
|
||||||
|
var hCid = key.slice(0, key.length - posClass.length);
|
||||||
|
var hEl = deckGrid.querySelector('.sig-card[data-card-id="' + hCid + '"]');
|
||||||
|
if (hEl) {
|
||||||
|
var a = hEl.querySelector('.sig-cursor' + posClass);
|
||||||
|
if (a) a.classList.remove('active');
|
||||||
|
}
|
||||||
|
delete _floatingCursors[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
var rect = cardEl.getBoundingClientRect();
|
||||||
|
var xFractions = [0.15, 0.5, 0.85];
|
||||||
|
var fc = document.createElement('i');
|
||||||
|
fc.className = 'fa-solid fa-thumbs-up sig-cursor-float sig-cursor-float--reserved';
|
||||||
|
fc.dataset.role = role;
|
||||||
|
fc.style.left = (rect.left + rect.width * xFractions[idx < 0 ? 1 : idx]) + 'px';
|
||||||
|
fc.style.top = rect.bottom + 'px';
|
||||||
|
_ensureCursorPortal().appendChild(fc);
|
||||||
|
_reservedFloats[role] = fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyReservation(cardId, role, reserved) {
|
||||||
|
var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]');
|
||||||
|
if (!cardEl) return;
|
||||||
|
|
||||||
|
if (reserved) {
|
||||||
|
cardEl.dataset.reservedBy = role;
|
||||||
|
cardEl.classList.add('sig-reserved');
|
||||||
|
if (role === userRole) {
|
||||||
|
_reservedCardId = cardId;
|
||||||
|
cardEl.classList.add('sig-reserved--own');
|
||||||
|
cardEl.classList.remove('sig-focused');
|
||||||
|
// Freeze stage on this card (temporarily unfreeze to populate it)
|
||||||
|
_stageFrozen = false;
|
||||||
|
updateStage(cardEl);
|
||||||
|
_stageFrozen = true;
|
||||||
|
stage.classList.add('sig-stage--frozen');
|
||||||
|
}
|
||||||
|
// Thumbs-up float for all reservations — own role sees their own indicator too
|
||||||
|
_placeReservedFloat(cardId, cardEl, role);
|
||||||
|
} else {
|
||||||
|
delete cardEl.dataset.reservedBy;
|
||||||
|
cardEl.classList.remove('sig-reserved', 'sig-reserved--own');
|
||||||
|
if (role === userRole) {
|
||||||
|
_reservedCardId = null;
|
||||||
|
_stageFrozen = false;
|
||||||
|
stage.classList.remove('sig-stage--frozen');
|
||||||
|
}
|
||||||
|
// Remove thumbs-up float for all releases — own role included
|
||||||
|
if (_reservedFloats[role]) {
|
||||||
|
_reservedFloats[role].remove();
|
||||||
|
delete _reservedFloats[role];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apply hover cursor (WS only — own hover is CSS :hover) ────────────
|
||||||
|
//
|
||||||
|
// Cursor icons are portaled to document root so they escape overflow/clip
|
||||||
|
// contexts in the deck grid. The in-card anchor elements only carry the
|
||||||
|
// .active class (for test assertions and the :has() z-index rule).
|
||||||
|
|
||||||
|
function _ensureCursorPortal() {
|
||||||
|
if (!_cursorPortal || !document.body.contains(_cursorPortal)) {
|
||||||
|
_cursorPortal = document.getElementById('id_sig_cursor_portal');
|
||||||
|
if (!_cursorPortal) {
|
||||||
|
_cursorPortal = document.createElement('div');
|
||||||
|
_cursorPortal.id = 'id_sig_cursor_portal';
|
||||||
|
document.body.appendChild(_cursorPortal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _cursorPortal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyHover(cardId, role, active) {
|
||||||
|
if (role === userRole) return;
|
||||||
|
if (_reservedFloats[role]) return; // role already has thumbs-up — ignore hover
|
||||||
|
var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]');
|
||||||
|
if (!cardEl) return;
|
||||||
|
|
||||||
|
var roles = POLARITY_ROLES[userPolarity] || [];
|
||||||
|
var idx = roles.indexOf(role);
|
||||||
|
var posClass = ['--left', '--mid', '--right'][idx] || '--left';
|
||||||
|
var anchor = cardEl.querySelector('.sig-cursor' + posClass);
|
||||||
|
if (!anchor) return;
|
||||||
|
|
||||||
|
var key = cardId + posClass;
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
anchor.classList.add('active'); // kept for test assertions + :has() z-index
|
||||||
|
|
||||||
|
// Place a fixed-position clone in the portal, positioned from card bounds
|
||||||
|
var rect = cardEl.getBoundingClientRect();
|
||||||
|
var xFractions = [0.15, 0.5, 0.85];
|
||||||
|
var fc = document.createElement('i');
|
||||||
|
fc.className = 'fa-solid fa-hand-pointer sig-cursor-float';
|
||||||
|
fc.dataset.role = role;
|
||||||
|
fc.style.left = (rect.left + rect.width * xFractions[idx]) + 'px';
|
||||||
|
fc.style.top = rect.bottom + 'px';
|
||||||
|
_ensureCursorPortal().appendChild(fc);
|
||||||
|
_floatingCursors[key] = fc;
|
||||||
|
} else {
|
||||||
|
anchor.classList.remove('active');
|
||||||
|
if (_floatingCursors[key]) {
|
||||||
|
_floatingCursors[key].remove();
|
||||||
|
delete _floatingCursors[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WS events ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
window.addEventListener('room:sig_reserved', function (e) {
|
||||||
|
if (!deckGrid) return;
|
||||||
|
applyReservation(String(e.detail.card_id), e.detail.role, e.detail.reserved);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('room:sig_hover', function (e) {
|
||||||
|
if (!deckGrid) return;
|
||||||
|
applyHover(String(e.detail.card_id), e.detail.role, e.detail.active);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── WS send ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function sendHover(cardId, active) {
|
||||||
|
if (!window._roomSocket || window._roomSocket.readyState !== WebSocket.OPEN) return;
|
||||||
|
window._roomSocket.send(JSON.stringify({
|
||||||
|
type: 'sig_hover', card_id: cardId, role: userRole, active: active,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
overlay = document.querySelector('.sig-overlay');
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
deckGrid = overlay.querySelector('.sig-deck-grid');
|
||||||
|
stage = overlay.querySelector('.sig-stage');
|
||||||
|
stageCard = stage.querySelector('.sig-stage-card');
|
||||||
|
statBlock = stage.querySelector('.sig-stat-block');
|
||||||
|
|
||||||
|
_flipBtn = statBlock.querySelector('.sig-flip-btn');
|
||||||
|
_cautionBtn = statBlock.querySelector('.sig-caution-btn');
|
||||||
|
_flipOrigLabel = _flipBtn.textContent;
|
||||||
|
_cautionOrigLabel = _cautionBtn.textContent;
|
||||||
|
|
||||||
|
_flipBtn.addEventListener('click', function () {
|
||||||
|
if (_flipBtn.classList.contains('btn-disabled')) return;
|
||||||
|
statBlock.classList.toggle('is-reversed');
|
||||||
|
});
|
||||||
|
|
||||||
|
cautionEl = stage.querySelector('.sig-caution-tooltip');
|
||||||
|
cautionEffect = cautionEl.querySelector('.sig-caution-effect');
|
||||||
|
cautionPrev = statBlock.querySelector('.sig-caution-prev');
|
||||||
|
cautionNext = statBlock.querySelector('.sig-caution-next');
|
||||||
|
cautionIndexEl = cautionEl.querySelector('.sig-caution-index');
|
||||||
|
|
||||||
|
// Clicking the tooltip (not nav buttons) dismisses it
|
||||||
|
cautionEl.addEventListener('click', function () {
|
||||||
|
_closeCaution();
|
||||||
|
});
|
||||||
|
|
||||||
|
_cautionBtn.addEventListener('click', function () {
|
||||||
|
if (_cautionBtn.classList.contains('btn-disabled')) return;
|
||||||
|
stage.classList.contains('sig-caution-open') ? _closeCaution() : _openCaution();
|
||||||
|
});
|
||||||
|
cautionPrev.addEventListener('click', function () {
|
||||||
|
_cautionIdx = (_cautionIdx - 1 + _cautionData.length) % _cautionData.length;
|
||||||
|
_renderCaution();
|
||||||
|
});
|
||||||
|
cautionNext.addEventListener('click', function () {
|
||||||
|
_cautionIdx = (_cautionIdx + 1) % _cautionData.length;
|
||||||
|
_renderCaution();
|
||||||
|
});
|
||||||
|
|
||||||
|
reserveUrl = overlay.dataset.reserveUrl;
|
||||||
|
userRole = overlay.dataset.userRole;
|
||||||
|
userPolarity= overlay.dataset.polarity;
|
||||||
|
|
||||||
|
// Restore reservations from server-rendered JSON (page-load state).
|
||||||
|
// Deferred to 'load' so sizeSigModal() (also a 'load' listener, registered
|
||||||
|
// in room.js before this script) has already applied paddingBottom and
|
||||||
|
// --sig-card-w before _placeReservedFloat calls getBoundingClientRect().
|
||||||
|
try {
|
||||||
|
var existing = JSON.parse(overlay.dataset.reservations || '{}');
|
||||||
|
if (Object.keys(existing).length) {
|
||||||
|
var _replayReservations = function () {
|
||||||
|
Object.keys(existing).forEach(function (cardId) {
|
||||||
|
applyReservation(cardId, existing[cardId], true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (document.readyState === 'complete') {
|
||||||
|
_replayReservations();
|
||||||
|
} else {
|
||||||
|
window.addEventListener('load', _replayReservations, { once: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { /* malformed JSON — ignore */ }
|
||||||
|
|
||||||
|
// Hover: update stage preview + broadcast cursor
|
||||||
|
deckGrid.querySelectorAll('.sig-card').forEach(function (card) {
|
||||||
|
card.addEventListener('mouseenter', onCardEnter);
|
||||||
|
card.addEventListener('mouseleave', onCardLeave);
|
||||||
|
card.addEventListener('touchstart', function (e) {
|
||||||
|
var card = e.currentTarget;
|
||||||
|
if (_reservedCardId) return; // locked until NVM — no preventDefault either
|
||||||
|
var reservedByOther = card.dataset.reservedBy && card.dataset.reservedBy !== userRole;
|
||||||
|
var isOwnReserved = card.classList.contains('sig-reserved--own');
|
||||||
|
if (reservedByOther || isOwnReserved) return;
|
||||||
|
// If the tap is on the OK button, let the synthetic click fire normally
|
||||||
|
if (e.target.closest('.sig-ok-btn')) return;
|
||||||
|
focusCard(card);
|
||||||
|
e.preventDefault(); // prevent ghost click on card body
|
||||||
|
}, { passive: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Touch outside the grid — dismiss stage preview (unfocused state only).
|
||||||
|
// Card touchstart doesn't stop propagation, so we guard with closest().
|
||||||
|
overlay.addEventListener('touchstart', function (e) {
|
||||||
|
if (_stageFrozen || !_focusedCardEl) return;
|
||||||
|
if (e.target.closest('.sig-deck-grid')) return;
|
||||||
|
deckGrid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) {
|
||||||
|
c.classList.remove('sig-focused');
|
||||||
|
});
|
||||||
|
updateStage(null);
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
|
// Click delegation: card body → focus (shows OK); OK/NVM buttons → reserve/release
|
||||||
|
deckGrid.addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.sig-ok-btn')) {
|
||||||
|
if (_reservedCardId) return; // already holding — must NVM first
|
||||||
|
var card = e.target.closest('.sig-card');
|
||||||
|
if (card) doReserve(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.target.closest('.sig-nvm-btn')) {
|
||||||
|
doRelease();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var card = e.target.closest('.sig-card');
|
||||||
|
if (!card) return;
|
||||||
|
if (_reservedCardId) return; // locked until NVM
|
||||||
|
var reservedByOther = card.dataset.reservedBy && card.dataset.reservedBy !== userRole;
|
||||||
|
var isOwnReserved = card.classList.contains('sig-reserved--own');
|
||||||
|
if (reservedByOther || isOwnReserved) return;
|
||||||
|
focusCard(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Test API ──────────────────────────────────────────────────────────
|
||||||
|
return {
|
||||||
|
_testInit: function () {
|
||||||
|
_focusedCardEl = null;
|
||||||
|
_reservedCardId = null;
|
||||||
|
_stageFrozen = false;
|
||||||
|
_requestInFlight = false;
|
||||||
|
_cautionData = [];
|
||||||
|
_cautionIdx = 0;
|
||||||
|
Object.keys(_floatingCursors).forEach(function (k) { _floatingCursors[k].remove(); });
|
||||||
|
_floatingCursors = {};
|
||||||
|
Object.keys(_reservedFloats).forEach(function (k) { _reservedFloats[k].remove(); });
|
||||||
|
_reservedFloats = {};
|
||||||
|
_cursorPortal = null;
|
||||||
|
init();
|
||||||
|
},
|
||||||
|
_setFrozen: function (v) { _stageFrozen = v; },
|
||||||
|
_setReservedCardId: function (id) { _reservedCardId = id; },
|
||||||
|
};
|
||||||
|
}());
|
||||||
523
src/apps/epic/static/apps/epic/tray.js
Normal file
523
src/apps/epic/static/apps/epic/tray.js
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
var Tray = (function () {
|
||||||
|
var _open = false;
|
||||||
|
// Fallback timeout (ms) after close() in placeCard in case transitionend
|
||||||
|
// never fires (e.g. CSS transitions disabled). Zeroed by reset() for tests.
|
||||||
|
var _closeTransitionMs = 600;
|
||||||
|
var _dragStartX = null;
|
||||||
|
var _dragStartY = null;
|
||||||
|
var _dragStartLeft = null;
|
||||||
|
var _dragStartTop = null;
|
||||||
|
var _dragHandled = false;
|
||||||
|
|
||||||
|
var _wrap = null;
|
||||||
|
var _btn = null;
|
||||||
|
var _tray = null;
|
||||||
|
var _grid = null;
|
||||||
|
|
||||||
|
// Role code → scrawl SVG name mapping for tray card display.
|
||||||
|
var _ROLE_SCRAWL = {
|
||||||
|
PC: 'Player', NC: 'Narrator', EC: 'Economist',
|
||||||
|
SC: 'Shepherd', AC: 'Alchemist', BC: 'Builder'
|
||||||
|
};
|
||||||
|
var _roleIconsUrl = null;
|
||||||
|
|
||||||
|
// Portrait bounds (X axis)
|
||||||
|
var _minLeft = 0;
|
||||||
|
var _maxLeft = 0;
|
||||||
|
|
||||||
|
// Landscape bounds (Y axis): _maxTop = closed (more negative), _minTop = open
|
||||||
|
var _minTop = 0;
|
||||||
|
var _maxTop = 0;
|
||||||
|
|
||||||
|
// Stored so reset() can remove them
|
||||||
|
var _onDocMove = null;
|
||||||
|
var _onDocUp = null;
|
||||||
|
var _onBtnClick = null;
|
||||||
|
var _closePendingHide = null; // portrait: pending display:none after slide
|
||||||
|
|
||||||
|
function _cancelPendingHide() {
|
||||||
|
if (_closePendingHide && _wrap) {
|
||||||
|
_wrap.removeEventListener('transitionend', _closePendingHide);
|
||||||
|
}
|
||||||
|
_closePendingHide = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Testing hook — null means use real window dimensions
|
||||||
|
var _landscapeOverride = null;
|
||||||
|
|
||||||
|
function _isLandscape() {
|
||||||
|
if (_landscapeOverride !== null) return _landscapeOverride;
|
||||||
|
return window.innerWidth > window.innerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the square cell size from the tray's interior dimension and set
|
||||||
|
// --tray-cell-size on #id_tray so SCSS grid tracks pick it up.
|
||||||
|
// Portrait: divide height / 8. Landscape: divide width / 8.
|
||||||
|
// In portrait the tray may be display:none; we show it with visibility:hidden
|
||||||
|
// briefly so clientHeight returns a real value, then restore display:none.
|
||||||
|
function _computeCellSize() {
|
||||||
|
if (!_tray) return;
|
||||||
|
var size;
|
||||||
|
if (_isLandscape()) {
|
||||||
|
size = Math.floor(_tray.clientWidth / 8);
|
||||||
|
} else {
|
||||||
|
var wasHidden = (_tray.style.display === 'none' || !_tray.style.display);
|
||||||
|
if (wasHidden) {
|
||||||
|
_tray.style.visibility = 'hidden';
|
||||||
|
_tray.style.display = 'grid';
|
||||||
|
}
|
||||||
|
size = Math.floor(_tray.clientHeight / 8);
|
||||||
|
if (wasHidden) {
|
||||||
|
_tray.style.display = 'none';
|
||||||
|
_tray.style.visibility = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (size > 0) {
|
||||||
|
_tray.style.setProperty('--tray-cell-size', size + 'px');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _computeBounds() {
|
||||||
|
if (_isLandscape()) {
|
||||||
|
// Landscape: the wrap slides on the Y axis.
|
||||||
|
// Structure (column-reverse): tray above, handle below.
|
||||||
|
// Wrap height is fixed to gearBtnTop so the handle bottom always
|
||||||
|
// meets the gear button when open. Tray is flex:1 and fills the rest.
|
||||||
|
// Open: wrap top = 0 (pinned to viewport top).
|
||||||
|
// Closed: wrap top = -(gearBtnTop - handleH) = tray fully above viewport.
|
||||||
|
var gearBtn = document.getElementById('id_gear_btn');
|
||||||
|
var gearBtnTop = window.innerHeight;
|
||||||
|
if (gearBtn) {
|
||||||
|
gearBtnTop = Math.round(gearBtn.getBoundingClientRect().top);
|
||||||
|
}
|
||||||
|
var handleH = (_btn && _btn.offsetHeight) || 48;
|
||||||
|
|
||||||
|
// Pin wrap height so handle bottom = gear btn top when open.
|
||||||
|
if (_wrap) _wrap.style.height = gearBtnTop + 'px';
|
||||||
|
|
||||||
|
// Open: wrap pinned to viewport top.
|
||||||
|
_minTop = 0;
|
||||||
|
|
||||||
|
// Closed: tray hidden above viewport, handle visible at y=0.
|
||||||
|
_maxTop = -(gearBtnTop - handleH);
|
||||||
|
} else {
|
||||||
|
// Portrait: wrap width = full viewport; handle parks at right edge.
|
||||||
|
var handleW = _btn.offsetWidth || 48;
|
||||||
|
if (_wrap) _wrap.style.width = window.innerWidth + 'px';
|
||||||
|
_minLeft = 0;
|
||||||
|
_maxLeft = window.innerWidth - handleW;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _applyVerticalBounds() {
|
||||||
|
// Portrait only: nudge wrap top/bottom to avoid overlapping nav/footer bars.
|
||||||
|
var nav = document.querySelector('nav');
|
||||||
|
var footer = document.querySelector('footer');
|
||||||
|
var rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||||
|
var inset = Math.round(rem * 0.125);
|
||||||
|
if (nav) {
|
||||||
|
var nb = nav.getBoundingClientRect();
|
||||||
|
if (nb.width > nb.height && nb.bottom < window.innerHeight * 0.4) {
|
||||||
|
_wrap.style.top = (Math.round(nb.bottom) + inset) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (footer) {
|
||||||
|
var fb = footer.getBoundingClientRect();
|
||||||
|
if (fb.width > fb.height && fb.top > window.innerHeight * 0.6) {
|
||||||
|
_wrap.style.bottom = (window.innerHeight - Math.round(fb.top) + inset) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (_open) return;
|
||||||
|
_cancelPendingHide(); // abort any in-flight portrait close animation
|
||||||
|
_open = true;
|
||||||
|
// Portrait only: toggle tray display.
|
||||||
|
// Landscape: tray is always display:block; wrap position controls visibility.
|
||||||
|
if (!_isLandscape() && _tray) _tray.style.display = 'grid';
|
||||||
|
if (_btn) _btn.classList.add('open');
|
||||||
|
if (_wrap) {
|
||||||
|
_wrap.classList.remove('tray-dragging');
|
||||||
|
if (_isLandscape()) {
|
||||||
|
_wrap.style.top = _minTop + 'px';
|
||||||
|
} else {
|
||||||
|
_wrap.style.left = _minLeft + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!_open) return;
|
||||||
|
_open = false;
|
||||||
|
if (_btn) _btn.classList.remove('open');
|
||||||
|
if (_wrap) {
|
||||||
|
_wrap.classList.remove('tray-dragging');
|
||||||
|
if (_isLandscape()) {
|
||||||
|
_wrap.style.top = _maxTop + 'px';
|
||||||
|
// Snap after the slide completes.
|
||||||
|
_closePendingHide = function (e) {
|
||||||
|
if (e.propertyName !== 'top') return;
|
||||||
|
if (_wrap) _wrap.removeEventListener('transitionend', _closePendingHide);
|
||||||
|
_closePendingHide = null;
|
||||||
|
_snapWrap();
|
||||||
|
};
|
||||||
|
_wrap.addEventListener('transitionend', _closePendingHide);
|
||||||
|
} else {
|
||||||
|
_wrap.style.left = _maxLeft + 'px';
|
||||||
|
// Snap first (tray still visible so it peeks), then hide tray.
|
||||||
|
_closePendingHide = function (e) {
|
||||||
|
if (e.propertyName !== 'left') return;
|
||||||
|
if (_wrap) _wrap.removeEventListener('transitionend', _closePendingHide);
|
||||||
|
_closePendingHide = null;
|
||||||
|
_snapWrap(function () {
|
||||||
|
if (!_open && _tray) _tray.style.display = 'none';
|
||||||
|
});
|
||||||
|
};
|
||||||
|
_wrap.addEventListener('transitionend', _closePendingHide);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpen() { return _open; }
|
||||||
|
|
||||||
|
// forceClose() — instant, no animation. Used by server-driven events
|
||||||
|
// (e.g. turn_changed) where the tray must be out of the way immediately.
|
||||||
|
function forceClose() {
|
||||||
|
_cancelPendingHide();
|
||||||
|
_open = false;
|
||||||
|
if (_btn) _btn.classList.remove('open');
|
||||||
|
if (_wrap) _wrap.classList.remove('wobble', 'snap');
|
||||||
|
if (_isLandscape()) {
|
||||||
|
if (_wrap) {
|
||||||
|
_wrap.classList.add('tray-dragging');
|
||||||
|
_wrap.style.top = _maxTop + 'px';
|
||||||
|
void _wrap.offsetWidth;
|
||||||
|
_wrap.classList.remove('tray-dragging');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_tray) _tray.style.display = 'none';
|
||||||
|
if (_wrap) {
|
||||||
|
_wrap.classList.add('tray-dragging');
|
||||||
|
_wrap.style.left = _maxLeft + 'px';
|
||||||
|
void _wrap.offsetWidth;
|
||||||
|
_wrap.classList.remove('tray-dragging');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _snapWrap(onDone) {
|
||||||
|
if (!_wrap) return;
|
||||||
|
_wrap.classList.add('snap');
|
||||||
|
_wrap.addEventListener('animationend', function handler() {
|
||||||
|
if (_wrap) _wrap.classList.remove('snap');
|
||||||
|
_wrap.removeEventListener('animationend', handler);
|
||||||
|
if (onDone) onDone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wobble() {
|
||||||
|
if (!_wrap) return;
|
||||||
|
// Portrait: show tray so it peeks in during the translateX animation,
|
||||||
|
// then re-hide it if the tray is still closed when the animation ends.
|
||||||
|
if (!_isLandscape() && _tray) _tray.style.display = 'grid';
|
||||||
|
_wrap.classList.add('wobble');
|
||||||
|
_wrap.addEventListener('animationend', function handler() {
|
||||||
|
_wrap.classList.remove('wobble');
|
||||||
|
_wrap.removeEventListener('animationend', handler);
|
||||||
|
if (!_isLandscape() && !_open && _tray) _tray.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// _arcIn — add .arc-in to cardEl, wait for animationend, remove it, call onComplete.
|
||||||
|
function _arcIn(cardEl, onComplete) {
|
||||||
|
cardEl.classList.add('arc-in');
|
||||||
|
cardEl.addEventListener('animationend', function handler() {
|
||||||
|
cardEl.removeEventListener('animationend', handler);
|
||||||
|
cardEl.classList.remove('arc-in');
|
||||||
|
if (onComplete) onComplete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeCard(roleCode, onComplete) — mark the first tray cell with the role,
|
||||||
|
// open the tray, arc-in the cell, then animated-close. Calls onComplete after
|
||||||
|
// the close slide finishes (transitionend), with a fallback timeout in case
|
||||||
|
// CSS transitions are disabled (e.g. test environments).
|
||||||
|
// The grid always contains exactly 8 .tray-cell elements (from the template);
|
||||||
|
// the first one receives .tray-role-card and data-role instead of a new element
|
||||||
|
// being inserted, so the cell count never changes.
|
||||||
|
function placeCard(roleCode, onComplete) {
|
||||||
|
if (!_grid) { if (onComplete) onComplete(); return; }
|
||||||
|
var firstCell = _grid.querySelector('.tray-cell');
|
||||||
|
if (!firstCell) { if (onComplete) onComplete(); return; }
|
||||||
|
|
||||||
|
firstCell.classList.add('tray-role-card');
|
||||||
|
firstCell.dataset.role = roleCode;
|
||||||
|
firstCell.textContent = '';
|
||||||
|
if (_roleIconsUrl) {
|
||||||
|
var img = document.createElement('img');
|
||||||
|
img.src = _roleIconsUrl + 'starter-role-' + (_ROLE_SCRAWL[roleCode] || 'Blank') + '.svg';
|
||||||
|
img.alt = roleCode;
|
||||||
|
firstCell.appendChild(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
open();
|
||||||
|
_arcIn(firstCell, function () {
|
||||||
|
close();
|
||||||
|
if (!onComplete) return;
|
||||||
|
if (!_wrap) { onComplete(); return; }
|
||||||
|
var propName = _isLandscape() ? 'top' : 'left';
|
||||||
|
var done = false;
|
||||||
|
function finish() {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
if (_wrap) _wrap.removeEventListener('transitionend', onCloseEnd);
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
function onCloseEnd(e) {
|
||||||
|
if (e.propertyName === propName) finish();
|
||||||
|
}
|
||||||
|
_wrap.addEventListener('transitionend', onCloseEnd);
|
||||||
|
setTimeout(finish, _closeTransitionMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startDrag(clientX, clientY) {
|
||||||
|
_dragHandled = false;
|
||||||
|
if (_wrap) _wrap.classList.add('tray-dragging');
|
||||||
|
if (_isLandscape()) {
|
||||||
|
_dragStartY = clientY;
|
||||||
|
_dragStartX = null;
|
||||||
|
_dragStartTop = _wrap ? (parseInt(_wrap.style.top, 10) || _maxTop) : _maxTop;
|
||||||
|
_dragStartLeft = null;
|
||||||
|
} else {
|
||||||
|
_dragStartX = clientX;
|
||||||
|
_dragStartY = null;
|
||||||
|
_dragStartLeft = _wrap ? (parseInt(_wrap.style.left, 10) || _maxLeft) : _maxLeft;
|
||||||
|
_dragStartTop = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force-close and reposition to settled bounds. Called on both 'resize'
|
||||||
|
// (snap without transition to avoid flicker during continuous events) and
|
||||||
|
// 'resize:end' (re-measures after the viewport has stopped moving).
|
||||||
|
function _reposition() {
|
||||||
|
_cancelPendingHide();
|
||||||
|
_open = false;
|
||||||
|
if (_btn) _btn.classList.remove('open');
|
||||||
|
if (_wrap) _wrap.classList.remove('wobble', 'snap', 'tray-dragging');
|
||||||
|
|
||||||
|
if (_isLandscape()) {
|
||||||
|
// Ensure tray is visible before measuring bounds.
|
||||||
|
if (_tray) _tray.style.display = 'grid';
|
||||||
|
if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; _wrap.style.width = ''; }
|
||||||
|
_computeBounds();
|
||||||
|
_computeCellSize();
|
||||||
|
if (_wrap) {
|
||||||
|
_wrap.classList.add('tray-dragging');
|
||||||
|
_wrap.style.top = _maxTop + 'px';
|
||||||
|
void _wrap.offsetWidth; // flush reflow so position lands before transition restored
|
||||||
|
_wrap.classList.remove('tray-dragging');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_tray) _tray.style.display = 'none';
|
||||||
|
if (_wrap) { _wrap.style.top = ''; _wrap.style.height = ''; _wrap.style.width = ''; }
|
||||||
|
_computeBounds();
|
||||||
|
_applyVerticalBounds();
|
||||||
|
_computeCellSize();
|
||||||
|
if (_wrap) {
|
||||||
|
_wrap.classList.add('tray-dragging');
|
||||||
|
_wrap.style.left = _maxLeft + 'px';
|
||||||
|
void _wrap.offsetWidth; // flush reflow
|
||||||
|
_wrap.classList.remove('tray-dragging');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
_wrap = document.getElementById('id_tray_wrap');
|
||||||
|
_btn = document.getElementById('id_tray_btn');
|
||||||
|
_tray = document.getElementById('id_tray');
|
||||||
|
_grid = document.getElementById('id_tray_grid');
|
||||||
|
_roleIconsUrl = (_grid && _grid.dataset.roleIconsUrl) || null;
|
||||||
|
if (!_btn) return;
|
||||||
|
|
||||||
|
if (_isLandscape()) {
|
||||||
|
// Show tray before measuring so offsetHeight includes it.
|
||||||
|
if (_tray) _tray.style.display = 'grid';
|
||||||
|
_computeBounds();
|
||||||
|
// Clear portrait's inline left/bottom so media-query CSS applies.
|
||||||
|
if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; }
|
||||||
|
if (_wrap) _wrap.style.top = _maxTop + 'px';
|
||||||
|
_computeCellSize();
|
||||||
|
} else {
|
||||||
|
// Clear landscape's inline top/height/width so portrait CSS applies.
|
||||||
|
if (_wrap) { _wrap.style.top = ''; _wrap.style.width = ''; }
|
||||||
|
_applyVerticalBounds();
|
||||||
|
_computeCellSize(); // wrap has correct height after _applyVerticalBounds
|
||||||
|
_computeBounds();
|
||||||
|
if (_wrap) _wrap.style.left = _maxLeft + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag start — pointer and mouse variants so Selenium W3C actions
|
||||||
|
// and synthetic Jasmine PointerEvents both work.
|
||||||
|
_btn.addEventListener('pointerdown', function (e) {
|
||||||
|
_startDrag(e.clientX, e.clientY);
|
||||||
|
try { _btn.setPointerCapture(e.pointerId); } catch (_) {}
|
||||||
|
});
|
||||||
|
_btn.addEventListener('mousedown', function (e) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
if (_dragStartX !== null || _dragStartY !== null) return;
|
||||||
|
_startDrag(e.clientX, e.clientY);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag move / end — on document so events that land elsewhere during
|
||||||
|
// the drag (no capture, or Selenium pointer quirks) still bubble here.
|
||||||
|
_onDocMove = function (e) {
|
||||||
|
if (!_wrap) return;
|
||||||
|
if (_isLandscape()) {
|
||||||
|
if (_dragStartY === null) return;
|
||||||
|
var newTop = _dragStartTop + (e.clientY - _dragStartY);
|
||||||
|
newTop = Math.max(_maxTop, Math.min(_minTop, newTop));
|
||||||
|
_wrap.style.top = newTop + 'px';
|
||||||
|
// Open when dragged below closed position; update state + class only.
|
||||||
|
// Tray display is not toggled in landscape — position controls visibility.
|
||||||
|
if (newTop > _maxTop) {
|
||||||
|
if (!_open) {
|
||||||
|
_open = true;
|
||||||
|
if (_btn) _btn.classList.add('open');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_open) {
|
||||||
|
_open = false;
|
||||||
|
if (_btn) _btn.classList.remove('open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_dragStartX === null) return;
|
||||||
|
var newLeft = _dragStartLeft + (e.clientX - _dragStartX);
|
||||||
|
newLeft = Math.max(_minLeft, Math.min(_maxLeft, newLeft));
|
||||||
|
_wrap.style.left = newLeft + 'px';
|
||||||
|
if (newLeft < _maxLeft) {
|
||||||
|
if (!_open) {
|
||||||
|
_open = true;
|
||||||
|
if (_tray) _tray.style.display = 'grid';
|
||||||
|
if (_btn) _btn.classList.add('open');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (_open) {
|
||||||
|
_open = false;
|
||||||
|
if (_tray) _tray.style.display = 'none';
|
||||||
|
if (_btn) _btn.classList.remove('open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('pointermove', _onDocMove);
|
||||||
|
document.addEventListener('mousemove', _onDocMove);
|
||||||
|
|
||||||
|
_onDocUp = function (e) {
|
||||||
|
if (_isLandscape()) {
|
||||||
|
if (_dragStartY !== null && Math.abs(e.clientY - _dragStartY) > 10) {
|
||||||
|
_dragHandled = true;
|
||||||
|
}
|
||||||
|
_dragStartY = null;
|
||||||
|
_dragStartTop = null;
|
||||||
|
} else {
|
||||||
|
if (_dragStartX !== null && Math.abs(e.clientX - _dragStartX) > 10) {
|
||||||
|
_dragHandled = true;
|
||||||
|
}
|
||||||
|
_dragStartX = null;
|
||||||
|
_dragStartLeft = null;
|
||||||
|
}
|
||||||
|
if (_wrap) _wrap.classList.remove('tray-dragging');
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerup', _onDocUp);
|
||||||
|
document.addEventListener('mouseup', _onDocUp);
|
||||||
|
|
||||||
|
_onBtnClick = function () {
|
||||||
|
if (_dragHandled) {
|
||||||
|
_dragHandled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_open) {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
_wobble();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_btn.addEventListener('click', _onBtnClick);
|
||||||
|
|
||||||
|
window.addEventListener('resize', _reposition);
|
||||||
|
window.addEventListener('resize:end', _reposition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset() — restores module state; used by Jasmine afterEach
|
||||||
|
function reset() {
|
||||||
|
_open = false;
|
||||||
|
_closeTransitionMs = 0;
|
||||||
|
_dragStartX = null;
|
||||||
|
_dragStartY = null;
|
||||||
|
_dragStartLeft = null;
|
||||||
|
_dragStartTop = null;
|
||||||
|
_dragHandled = false;
|
||||||
|
_landscapeOverride = null;
|
||||||
|
// Restore portrait default (display:none); landscape init() will show it.
|
||||||
|
if (_tray) {
|
||||||
|
_tray.style.display = 'none';
|
||||||
|
_tray.style.removeProperty('--tray-cell-size');
|
||||||
|
}
|
||||||
|
if (_btn) _btn.classList.remove('open');
|
||||||
|
if (_wrap) {
|
||||||
|
_wrap.classList.remove('wobble', 'snap', 'tray-dragging');
|
||||||
|
_wrap.style.left = '';
|
||||||
|
_wrap.style.top = '';
|
||||||
|
_wrap.style.height = '';
|
||||||
|
_wrap.style.width = '';
|
||||||
|
}
|
||||||
|
if (_onDocMove) {
|
||||||
|
document.removeEventListener('pointermove', _onDocMove);
|
||||||
|
document.removeEventListener('mousemove', _onDocMove);
|
||||||
|
_onDocMove = null;
|
||||||
|
}
|
||||||
|
if (_onDocUp) {
|
||||||
|
document.removeEventListener('pointerup', _onDocUp);
|
||||||
|
document.removeEventListener('mouseup', _onDocUp);
|
||||||
|
_onDocUp = null;
|
||||||
|
}
|
||||||
|
if (_onBtnClick && _btn) {
|
||||||
|
_btn.removeEventListener('click', _onBtnClick);
|
||||||
|
_onBtnClick = null;
|
||||||
|
}
|
||||||
|
_cancelPendingHide();
|
||||||
|
// Clear any role-card state from tray cells (Jasmine afterEach)
|
||||||
|
if (_grid) {
|
||||||
|
_grid.querySelectorAll('.tray-cell').forEach(function (el) {
|
||||||
|
el.classList.remove('tray-role-card', 'arc-in');
|
||||||
|
el.textContent = '';
|
||||||
|
delete el.dataset.role;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_wrap = null;
|
||||||
|
_btn = null;
|
||||||
|
_tray = null;
|
||||||
|
_grid = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init: init,
|
||||||
|
open: open,
|
||||||
|
close: close,
|
||||||
|
forceClose: forceClose,
|
||||||
|
isOpen: isOpen,
|
||||||
|
placeCard: placeCard,
|
||||||
|
reset: reset,
|
||||||
|
_testSetLandscape: function (v) { _landscapeOverride = v; },
|
||||||
|
};
|
||||||
|
}());
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
|
from channels.db import database_sync_to_async
|
||||||
from channels.testing.websocket import WebsocketCommunicator
|
from channels.testing.websocket import WebsocketCommunicator
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
from django.test import SimpleTestCase, override_settings
|
from django.test import Client, SimpleTestCase, TransactionTestCase, override_settings, tag
|
||||||
|
|
||||||
|
from apps.epic.models import Room, TableSeat
|
||||||
|
from apps.lyric.models import User
|
||||||
from core.asgi import application
|
from core.asgi import application
|
||||||
|
|
||||||
|
|
||||||
@@ -52,19 +55,33 @@ class RoomConsumerTest(SimpleTestCase):
|
|||||||
|
|
||||||
await communicator.disconnect()
|
await communicator.disconnect()
|
||||||
|
|
||||||
async def test_receives_roles_revealed_broadcast(self):
|
async def test_receives_all_roles_filled_broadcast(self):
|
||||||
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
|
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
|
||||||
await communicator.connect()
|
await communicator.connect()
|
||||||
|
|
||||||
channel_layer = get_channel_layer()
|
channel_layer = get_channel_layer()
|
||||||
await channel_layer.group_send(
|
await channel_layer.group_send(
|
||||||
"room_00000000-0000-0000-0000-000000000001",
|
"room_00000000-0000-0000-0000-000000000001",
|
||||||
{"type": "roles_revealed", "assignments": {"1": "PC", "2": "BC"}},
|
{"type": "all_roles_filled"},
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await communicator.receive_json_from()
|
response = await communicator.receive_json_from()
|
||||||
self.assertEqual(response["type"], "roles_revealed")
|
self.assertEqual(response["type"], "all_roles_filled")
|
||||||
self.assertIn("assignments", response)
|
|
||||||
|
await communicator.disconnect()
|
||||||
|
|
||||||
|
async def test_receives_sig_select_started_broadcast(self):
|
||||||
|
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
|
||||||
|
await communicator.connect()
|
||||||
|
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
await channel_layer.group_send(
|
||||||
|
"room_00000000-0000-0000-0000-000000000001",
|
||||||
|
{"type": "sig_select_started"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await communicator.receive_json_from()
|
||||||
|
self.assertEqual(response["type"], "sig_select_started")
|
||||||
|
|
||||||
await communicator.disconnect()
|
await communicator.disconnect()
|
||||||
|
|
||||||
@@ -83,3 +100,162 @@ class RoomConsumerTest(SimpleTestCase):
|
|||||||
self.assertEqual(response["gate_state"], "some_state")
|
self.assertEqual(response["gate_state"], "some_state")
|
||||||
|
|
||||||
await communicator.disconnect()
|
await communicator.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
@tag('channels')
|
||||||
|
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
|
||||||
|
class CursorMoveConsumerTest(TransactionTestCase):
|
||||||
|
"""Cursor moves are broadcast only within the same polarity group
|
||||||
|
(levity: PC/NC/SC — gravity: BC/EC/AC)."""
|
||||||
|
|
||||||
|
async def _make_communicator(self, user, room):
|
||||||
|
client = Client()
|
||||||
|
await database_sync_to_async(client.force_login)(user)
|
||||||
|
session_key = await database_sync_to_async(lambda: client.session.session_key)()
|
||||||
|
comm = WebsocketCommunicator(
|
||||||
|
application,
|
||||||
|
f"/ws/room/{room.id}/",
|
||||||
|
headers=[(b"cookie", f"sessionid={session_key}".encode())],
|
||||||
|
)
|
||||||
|
connected, _ = await comm.connect()
|
||||||
|
self.assertTrue(connected)
|
||||||
|
return comm
|
||||||
|
|
||||||
|
async def test_levity_cursor_received_by_fellow_levity_player(self):
|
||||||
|
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||||
|
nc_user = await database_sync_to_async(User.objects.create)(email="nc@test.io")
|
||||||
|
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||||
|
)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=nc_user, slot_number=2, role="NC"
|
||||||
|
)
|
||||||
|
|
||||||
|
pc_comm = await self._make_communicator(pc_user, room)
|
||||||
|
nc_comm = await self._make_communicator(nc_user, room)
|
||||||
|
|
||||||
|
await pc_comm.send_json_to({"type": "cursor_move", "x": 0.5, "y": 0.3})
|
||||||
|
|
||||||
|
msg = await nc_comm.receive_json_from(timeout=2)
|
||||||
|
self.assertEqual(msg["type"], "cursor_move")
|
||||||
|
self.assertAlmostEqual(msg["x"], 0.5)
|
||||||
|
|
||||||
|
await pc_comm.disconnect()
|
||||||
|
await nc_comm.disconnect()
|
||||||
|
|
||||||
|
async def test_levity_cursor_not_received_by_gravity_player(self):
|
||||||
|
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||||
|
bc_user = await database_sync_to_async(User.objects.create)(email="bc@test.io")
|
||||||
|
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||||
|
)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=bc_user, slot_number=2, role="BC"
|
||||||
|
)
|
||||||
|
|
||||||
|
pc_comm = await self._make_communicator(pc_user, room)
|
||||||
|
bc_comm = await self._make_communicator(bc_user, room)
|
||||||
|
|
||||||
|
await pc_comm.send_json_to({"type": "cursor_move", "x": 0.5, "y": 0.3})
|
||||||
|
|
||||||
|
self.assertTrue(await bc_comm.receive_nothing(timeout=1))
|
||||||
|
|
||||||
|
await pc_comm.disconnect()
|
||||||
|
await bc_comm.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
@tag('channels')
|
||||||
|
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
|
||||||
|
class SigHoverConsumerTest(TransactionTestCase):
|
||||||
|
"""sig_hover messages sent by a client are forwarded within the polarity group only."""
|
||||||
|
|
||||||
|
async def _make_communicator(self, user, room):
|
||||||
|
client = Client()
|
||||||
|
await database_sync_to_async(client.force_login)(user)
|
||||||
|
session_key = await database_sync_to_async(lambda: client.session.session_key)()
|
||||||
|
comm = WebsocketCommunicator(
|
||||||
|
application,
|
||||||
|
f"/ws/room/{room.id}/",
|
||||||
|
headers=[(b"cookie", f"sessionid={session_key}".encode())],
|
||||||
|
)
|
||||||
|
connected, _ = await comm.connect()
|
||||||
|
self.assertTrue(connected)
|
||||||
|
return comm
|
||||||
|
|
||||||
|
async def test_sig_hover_forwarded_to_polarity_group(self):
|
||||||
|
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||||
|
nc_user = await database_sync_to_async(User.objects.create)(email="nc@test.io")
|
||||||
|
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||||
|
)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=nc_user, slot_number=2, role="NC"
|
||||||
|
)
|
||||||
|
|
||||||
|
pc_comm = await self._make_communicator(pc_user, room)
|
||||||
|
nc_comm = await self._make_communicator(nc_user, room)
|
||||||
|
|
||||||
|
await pc_comm.send_json_to({
|
||||||
|
"type": "sig_hover", "card_id": "abc-123", "role": "PC", "active": True
|
||||||
|
})
|
||||||
|
|
||||||
|
msg = await nc_comm.receive_json_from(timeout=2)
|
||||||
|
self.assertEqual(msg["type"], "sig_hover")
|
||||||
|
self.assertEqual(msg["card_id"], "abc-123")
|
||||||
|
self.assertEqual(msg["role"], "PC")
|
||||||
|
self.assertTrue(msg["active"])
|
||||||
|
|
||||||
|
await pc_comm.disconnect()
|
||||||
|
await nc_comm.disconnect()
|
||||||
|
|
||||||
|
async def test_sig_hover_not_forwarded_to_other_polarity(self):
|
||||||
|
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||||
|
bc_user = await database_sync_to_async(User.objects.create)(email="bc@test.io")
|
||||||
|
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||||
|
)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=bc_user, slot_number=2, role="BC"
|
||||||
|
)
|
||||||
|
|
||||||
|
pc_comm = await self._make_communicator(pc_user, room)
|
||||||
|
bc_comm = await self._make_communicator(bc_user, room)
|
||||||
|
|
||||||
|
await pc_comm.send_json_to({
|
||||||
|
"type": "sig_hover", "card_id": "abc-123", "role": "PC", "active": True
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertTrue(await bc_comm.receive_nothing(timeout=1))
|
||||||
|
|
||||||
|
await pc_comm.disconnect()
|
||||||
|
await bc_comm.disconnect()
|
||||||
|
|
||||||
|
async def test_sig_reserved_broadcast_received_by_polarity_group(self):
|
||||||
|
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||||
|
nc_user = await database_sync_to_async(User.objects.create)(email="nc@test.io")
|
||||||
|
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||||
|
)
|
||||||
|
await database_sync_to_async(TableSeat.objects.create)(
|
||||||
|
room=room, gamer=nc_user, slot_number=2, role="NC"
|
||||||
|
)
|
||||||
|
|
||||||
|
nc_comm = await self._make_communicator(nc_user, room)
|
||||||
|
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
await channel_layer.group_send(
|
||||||
|
f"cursors_{room.id}_levity",
|
||||||
|
{"type": "sig_reserved", "card_id": "card-xyz", "role": "PC", "reserved": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = await nc_comm.receive_json_from(timeout=2)
|
||||||
|
self.assertEqual(msg["type"], "sig_reserved")
|
||||||
|
self.assertEqual(msg["card_id"], "card-xyz")
|
||||||
|
self.assertTrue(msg["reserved"])
|
||||||
|
|
||||||
|
await nc_comm.disconnect()
|
||||||
|
|||||||
@@ -4,8 +4,14 @@ from django.test import TestCase
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from django.db import IntegrityError
|
||||||
|
|
||||||
from apps.lyric.models import Token, User
|
from apps.lyric.models import Token, User
|
||||||
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat, debit_token, select_token
|
from apps.epic.models import (
|
||||||
|
DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
||||||
|
debit_token, select_token, sig_deck_cards, levity_sig_cards, gravity_sig_cards,
|
||||||
|
sig_seat_order, active_sig_seat,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RoomCreationTest(TestCase):
|
class RoomCreationTest(TestCase):
|
||||||
@@ -214,3 +220,313 @@ class RoomInviteTest(TestCase):
|
|||||||
Q(invites__invitee_email=friend.email, invites__status=RoomInvite.PENDING)
|
Q(invites__invitee_email=friend.email, invites__status=RoomInvite.PENDING)
|
||||||
).distinct()
|
).distinct()
|
||||||
self.assertIn(self.room, rooms)
|
self.assertIn(self.room, rooms)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Significator deck helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||||||
|
|
||||||
|
|
||||||
|
def _full_sig_room(name="Sig Room", role_order=None):
|
||||||
|
"""Return (room, gamers, earthman) with all 6 seats filled, roles assigned,
|
||||||
|
table_status=SIG_SELECT, and every gamer's equipped_deck set to Earthman.
|
||||||
|
Uses get_or_create for DeckVariant — migration data persists in TestCase."""
|
||||||
|
if role_order is None:
|
||||||
|
role_order = SIG_SEAT_ORDER[:]
|
||||||
|
earthman, _ = DeckVariant.objects.get_or_create(
|
||||||
|
slug="earthman",
|
||||||
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||||
|
)
|
||||||
|
owner = User.objects.create(email="founder@sig.io")
|
||||||
|
gamers = [owner]
|
||||||
|
for i in range(2, 7):
|
||||||
|
gamers.append(User.objects.create(email=f"g{i}@sig.io"))
|
||||||
|
for gamer in gamers:
|
||||||
|
gamer.equipped_deck = earthman
|
||||||
|
gamer.save(update_fields=["equipped_deck"])
|
||||||
|
room = Room.objects.create(name=name, owner=owner)
|
||||||
|
for i, (gamer, role) in enumerate(zip(gamers, role_order), start=1):
|
||||||
|
slot = room.gate_slots.get(slot_number=i)
|
||||||
|
slot.gamer = gamer
|
||||||
|
slot.status = GateSlot.FILLED
|
||||||
|
slot.save()
|
||||||
|
TableSeat.objects.create(
|
||||||
|
room=room, gamer=gamer, slot_number=i,
|
||||||
|
role=role, role_revealed=True,
|
||||||
|
)
|
||||||
|
room.table_status = Room.SIG_SELECT
|
||||||
|
room.save()
|
||||||
|
return room, gamers, earthman
|
||||||
|
|
||||||
|
|
||||||
|
class SigDeckCompositionTest(TestCase):
|
||||||
|
"""sig_deck_cards(room) returns exactly 36 cards with correct suit/arcana split."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.room, self.gamers, self.earthman = _full_sig_room()
|
||||||
|
|
||||||
|
def test_sig_deck_returns_36_cards(self):
|
||||||
|
cards = sig_deck_cards(self.room)
|
||||||
|
self.assertEqual(len(cards), 36)
|
||||||
|
|
||||||
|
def test_sc_ac_contribute_court_cards_of_blades_and_grails(self):
|
||||||
|
cards = sig_deck_cards(self.room)
|
||||||
|
sc_ac = [c for c in cards if c.suit in ("BLADES", "GRAILS")]
|
||||||
|
# M/J/Q/K × 2 suits × 2 roles = 16
|
||||||
|
self.assertEqual(len(sc_ac), 16)
|
||||||
|
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in sc_ac))
|
||||||
|
|
||||||
|
def test_pc_bc_contribute_court_cards_of_brands_and_crowns(self):
|
||||||
|
cards = sig_deck_cards(self.room)
|
||||||
|
pc_bc = [c for c in cards if c.suit in ("BRANDS", "CROWNS")]
|
||||||
|
self.assertEqual(len(pc_bc), 16)
|
||||||
|
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in pc_bc))
|
||||||
|
|
||||||
|
def test_nc_ec_contribute_schiz_and_chancellor(self):
|
||||||
|
cards = sig_deck_cards(self.room)
|
||||||
|
major = [c for c in cards if c.arcana == "MAJOR"]
|
||||||
|
self.assertEqual(len(major), 4)
|
||||||
|
self.assertEqual(sorted(c.number for c in major), [0, 0, 1, 1])
|
||||||
|
|
||||||
|
def test_each_card_appears_twice_once_per_pile(self):
|
||||||
|
"""18 unique card specs × 2 (levity + gravity) = 36 total."""
|
||||||
|
cards = sig_deck_cards(self.room)
|
||||||
|
slugs = [c.slug for c in cards]
|
||||||
|
unique_slugs = set(slugs)
|
||||||
|
self.assertEqual(len(unique_slugs), 18)
|
||||||
|
self.assertTrue(all(slugs.count(s) == 2 for s in unique_slugs))
|
||||||
|
|
||||||
|
|
||||||
|
class SigSeatOrderTest(TestCase):
|
||||||
|
"""sig_seat_order() and active_sig_seat() return seats in PC→NC→EC→SC→AC→BC order."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Assign roles in reverse of canonical order to prove reordering works
|
||||||
|
self.room, self.gamers, _ = _full_sig_room(
|
||||||
|
name="Order Room",
|
||||||
|
role_order=["BC", "AC", "SC", "EC", "NC", "PC"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sig_seat_order_returns_canonical_role_sequence(self):
|
||||||
|
seats = sig_seat_order(self.room)
|
||||||
|
self.assertEqual([s.role for s in seats], SIG_SEAT_ORDER)
|
||||||
|
|
||||||
|
def test_active_sig_seat_is_first_seat_without_significator(self):
|
||||||
|
seat = active_sig_seat(self.room)
|
||||||
|
self.assertEqual(seat.role, "PC")
|
||||||
|
|
||||||
|
def test_active_sig_seat_advances_after_significator_set(self):
|
||||||
|
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
card = TarotCard.objects.filter(deck_variant=earthman, arcana="MINOR").first()
|
||||||
|
pc_seat.significator = card
|
||||||
|
pc_seat.save()
|
||||||
|
seat = active_sig_seat(self.room)
|
||||||
|
self.assertEqual(seat.role, "NC")
|
||||||
|
|
||||||
|
def test_active_sig_seat_is_none_when_all_chosen(self):
|
||||||
|
earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
cards = list(TarotCard.objects.filter(deck_variant=earthman))
|
||||||
|
for i, seat in enumerate(TableSeat.objects.filter(room=self.room)):
|
||||||
|
seat.significator = cards[i]
|
||||||
|
seat.save()
|
||||||
|
self.assertIsNone(active_sig_seat(self.room))
|
||||||
|
|
||||||
|
|
||||||
|
class SigCardFieldTest(TestCase):
|
||||||
|
"""TableSeat.significator FK to TarotCard — default null, assignable."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
earthman, _ = DeckVariant.objects.get_or_create(
|
||||||
|
slug="earthman",
|
||||||
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||||
|
)
|
||||||
|
self.card = TarotCard.objects.get(
|
||||||
|
deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=11,
|
||||||
|
)
|
||||||
|
owner = User.objects.create(email="owner@test.io")
|
||||||
|
room = Room.objects.create(name="Field Test", owner=owner)
|
||||||
|
self.seat = TableSeat.objects.create(room=room, gamer=owner, slot_number=1, role="PC")
|
||||||
|
|
||||||
|
def test_significator_defaults_to_none(self):
|
||||||
|
self.assertIsNone(self.seat.significator)
|
||||||
|
|
||||||
|
def test_significator_can_be_assigned(self):
|
||||||
|
self.seat.significator = self.card
|
||||||
|
self.seat.save()
|
||||||
|
self.seat.refresh_from_db()
|
||||||
|
self.assertEqual(self.seat.significator, self.card)
|
||||||
|
|
||||||
|
def test_significator_nullable_on_delete(self):
|
||||||
|
self.seat.significator = self.card
|
||||||
|
self.seat.save()
|
||||||
|
self.card.delete()
|
||||||
|
self.seat.refresh_from_db()
|
||||||
|
self.assertIsNone(self.seat.significator)
|
||||||
|
|
||||||
|
|
||||||
|
# ── SigReservation model ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_sig_card(deck_variant, suit, number):
|
||||||
|
name_map = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
|
||||||
|
card, _ = TarotCard.objects.get_or_create(
|
||||||
|
deck_variant=deck_variant,
|
||||||
|
slug=f"{name_map[number].lower()}-of-{suit.lower()}-em",
|
||||||
|
defaults={
|
||||||
|
"arcana": "MINOR", "suit": suit, "number": number,
|
||||||
|
"name": f"{name_map[number]} of {suit.capitalize()}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
class SigReservationModelTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.earthman, _ = DeckVariant.objects.get_or_create(
|
||||||
|
slug="earthman",
|
||||||
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||||
|
)
|
||||||
|
self.owner = User.objects.create(email="founder@test.io")
|
||||||
|
self.room = Room.objects.create(name="Sig Room", owner=self.owner)
|
||||||
|
self.card = _make_sig_card(self.earthman, "WANDS", 14)
|
||||||
|
self.seat = TableSeat.objects.create(
|
||||||
|
room=self.room, gamer=self.owner, slot_number=1, role="PC"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_can_create_sig_reservation(self):
|
||||||
|
res = SigReservation.objects.create(
|
||||||
|
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||||
|
)
|
||||||
|
self.assertEqual(res.role, "PC")
|
||||||
|
self.assertEqual(res.polarity, "levity")
|
||||||
|
self.assertIsNotNone(res.reserved_at)
|
||||||
|
|
||||||
|
def test_one_reservation_per_gamer_per_room(self):
|
||||||
|
SigReservation.objects.create(
|
||||||
|
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||||
|
)
|
||||||
|
card2 = _make_sig_card(self.earthman, "CUPS", 13)
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
SigReservation.objects.create(
|
||||||
|
room=self.room, gamer=self.owner, card=card2, role="PC", polarity="levity"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_same_card_blocked_within_same_polarity(self):
|
||||||
|
gamer2 = User.objects.create(email="nc@test.io")
|
||||||
|
TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="NC")
|
||||||
|
SigReservation.objects.create(
|
||||||
|
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||||
|
)
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
SigReservation.objects.create(
|
||||||
|
room=self.room, gamer=gamer2, card=self.card, role="NC", polarity="levity"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_same_card_allowed_across_polarity(self):
|
||||||
|
"""A gravity gamer may reserve the same card instance as a levity gamer
|
||||||
|
— each polarity has its own independent pile."""
|
||||||
|
gamer2 = User.objects.create(email="bc@test.io")
|
||||||
|
TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="BC")
|
||||||
|
SigReservation.objects.create(
|
||||||
|
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||||
|
)
|
||||||
|
res2 = SigReservation.objects.create(
|
||||||
|
room=self.room, gamer=gamer2, card=self.card, role="BC", polarity="gravity"
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(res2.pk)
|
||||||
|
|
||||||
|
def test_deleting_reservation_clears_slot(self):
|
||||||
|
res = SigReservation.objects.create(
|
||||||
|
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||||
|
)
|
||||||
|
res.delete()
|
||||||
|
self.assertFalse(SigReservation.objects.filter(room=self.room, gamer=self.owner).exists())
|
||||||
|
|
||||||
|
|
||||||
|
class SigCardHelperTest(TestCase):
|
||||||
|
"""levity_sig_cards() and gravity_sig_cards() return 18 cards each.
|
||||||
|
Relies on the Earthman deck seeded by migrations (no manual card creation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Earthman deck is already seeded by migrations
|
||||||
|
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
self.owner = User.objects.create(email="founder@test.io")
|
||||||
|
self.owner.equipped_deck = self.earthman
|
||||||
|
self.owner.save()
|
||||||
|
self.room = Room.objects.create(name="Card Test", owner=self.owner)
|
||||||
|
|
||||||
|
def test_levity_sig_cards_returns_18(self):
|
||||||
|
cards = levity_sig_cards(self.room)
|
||||||
|
self.assertEqual(len(cards), 18)
|
||||||
|
|
||||||
|
def test_gravity_sig_cards_returns_18(self):
|
||||||
|
cards = gravity_sig_cards(self.room)
|
||||||
|
self.assertEqual(len(cards), 18)
|
||||||
|
|
||||||
|
def test_levity_and_gravity_share_same_card_objects(self):
|
||||||
|
"""Both piles draw from the same 18 TarotCard instances — visual distinction
|
||||||
|
comes from CSS polarity class, not separate card model records."""
|
||||||
|
levity = levity_sig_cards(self.room)
|
||||||
|
gravity = gravity_sig_cards(self.room)
|
||||||
|
self.assertEqual(
|
||||||
|
sorted(c.pk for c in levity),
|
||||||
|
sorted(c.pk for c in gravity),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_returns_empty_when_no_equipped_deck(self):
|
||||||
|
self.owner.equipped_deck = None
|
||||||
|
self.owner.save()
|
||||||
|
self.assertEqual(levity_sig_cards(self.room), [])
|
||||||
|
self.assertEqual(gravity_sig_cards(self.room), [])
|
||||||
|
|
||||||
|
|
||||||
|
class TarotCardCautionsTest(TestCase):
|
||||||
|
"""TarotCard.cautions JSONField — field existence and Schizo seed data."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
|
||||||
|
def test_cautions_field_saves_and_retrieves_list(self):
|
||||||
|
card = TarotCard.objects.create(
|
||||||
|
deck_variant=self.earthman,
|
||||||
|
arcana="MINOR",
|
||||||
|
suit="CROWNS",
|
||||||
|
number=99,
|
||||||
|
name="Test Card",
|
||||||
|
slug="test-card-cautions",
|
||||||
|
cautions=["First caution.", "Second caution."],
|
||||||
|
)
|
||||||
|
card.refresh_from_db()
|
||||||
|
self.assertEqual(card.cautions, ["First caution.", "Second caution."])
|
||||||
|
|
||||||
|
def test_cautions_defaults_to_empty_list(self):
|
||||||
|
card = TarotCard.objects.create(
|
||||||
|
deck_variant=self.earthman,
|
||||||
|
arcana="MINOR",
|
||||||
|
suit="CROWNS",
|
||||||
|
number=98,
|
||||||
|
name="Default Cautions Card",
|
||||||
|
slug="default-cautions-card",
|
||||||
|
)
|
||||||
|
self.assertEqual(card.cautions, [])
|
||||||
|
|
||||||
|
def test_schizo_has_4_cautions(self):
|
||||||
|
schizo = TarotCard.objects.get(
|
||||||
|
deck_variant=self.earthman, arcana="MAJOR", number=1
|
||||||
|
)
|
||||||
|
self.assertEqual(len(schizo.cautions), 4)
|
||||||
|
|
||||||
|
def test_schizo_caution_references_the_pervert(self):
|
||||||
|
schizo = TarotCard.objects.get(
|
||||||
|
deck_variant=self.earthman, arcana="MAJOR", number=1
|
||||||
|
)
|
||||||
|
self.assertIn("The Pervert", schizo.cautions[0])
|
||||||
|
|
||||||
|
def test_schizo_cautions_use_reverse_language(self):
|
||||||
|
schizo = TarotCard.objects.get(
|
||||||
|
deck_variant=self.earthman, arcana="MAJOR", number=1
|
||||||
|
)
|
||||||
|
for caution in schizo.cautions:
|
||||||
|
self.assertIn("reverse", caution)
|
||||||
|
self.assertNotIn("transform", caution)
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from unittest.mock import patch
|
from unittest.mock import ANY, patch
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.drama.models import GameEvent
|
||||||
from apps.lyric.models import Token, User
|
from apps.lyric.models import Token, User
|
||||||
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat
|
from apps.epic.models import (
|
||||||
|
DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RoomCreationViewTest(TestCase):
|
class RoomCreationViewTest(TestCase):
|
||||||
@@ -365,85 +368,172 @@ class RoleSelectRenderingTest(TestCase):
|
|||||||
self.room.save()
|
self.room.save()
|
||||||
for i, gamer in enumerate(self.gamers, start=1):
|
for i, gamer in enumerate(self.gamers, start=1):
|
||||||
TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
|
TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
|
||||||
|
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||||||
|
|
||||||
def test_room_view_includes_card_stack_when_role_select(self):
|
def test_room_view_includes_card_stack_when_role_select(self):
|
||||||
self.client.force_login(self.founder)
|
self.client.force_login(self.founder)
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
self.url
|
||||||
)
|
)
|
||||||
self.assertContains(response, "card-stack")
|
self.assertContains(response, "card-stack")
|
||||||
|
|
||||||
def test_card_stack_eligible_for_slot1_gamer(self):
|
def test_card_stack_eligible_for_slot1_gamer(self):
|
||||||
self.client.force_login(self.founder)
|
self.client.force_login(self.founder)
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
self.url
|
||||||
)
|
)
|
||||||
self.assertContains(response, 'data-state="eligible"')
|
self.assertContains(response, 'data-state="eligible"')
|
||||||
|
|
||||||
def test_card_stack_ineligible_for_slot2_gamer(self):
|
def test_card_stack_ineligible_for_slot2_gamer(self):
|
||||||
self.client.force_login(self.gamers[1])
|
self.client.force_login(self.gamers[1])
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
self.url
|
||||||
)
|
)
|
||||||
self.assertContains(response, 'data-state="ineligible"')
|
self.assertContains(response, 'data-state="ineligible"')
|
||||||
|
|
||||||
def test_card_stack_ineligible_shows_fa_ban(self):
|
def test_card_stack_ineligible_shows_fa_ban(self):
|
||||||
self.client.force_login(self.gamers[1])
|
self.client.force_login(self.gamers[1])
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
self.url
|
||||||
)
|
)
|
||||||
self.assertContains(response, "fa-ban")
|
self.assertContains(response, "fa-ban")
|
||||||
|
|
||||||
def test_card_stack_eligible_omits_fa_ban(self):
|
def test_card_stack_eligible_omits_fa_ban(self):
|
||||||
self.client.force_login(self.founder)
|
self.client.force_login(self.founder)
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
self.url
|
||||||
)
|
)
|
||||||
self.assertNotContains(response, "fa-ban")
|
# Seat ban icons carry "position-status-icon"; card-stack ban does not.
|
||||||
|
# Assert the bare "fa-solid fa-ban" (card-stack form) is absent.
|
||||||
|
self.assertNotContains(response, 'class="fa-solid fa-ban"')
|
||||||
|
|
||||||
def test_gatekeeper_overlay_absent_when_role_select(self):
|
def test_gatekeeper_overlay_absent_when_role_select(self):
|
||||||
self.client.force_login(self.founder)
|
self.client.force_login(self.founder)
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
self.url
|
||||||
)
|
)
|
||||||
self.assertNotContains(response, "gate-overlay")
|
self.assertNotContains(response, "gate-overlay")
|
||||||
|
|
||||||
|
def test_tray_wrap_has_role_select_phase_class(self):
|
||||||
|
# Tray handle hidden until gamer confirms a role pick
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, 'id="id_tray_wrap" class="role-select-phase"')
|
||||||
|
|
||||||
|
def test_tray_absent_during_gatekeeper_phase(self):
|
||||||
|
# Tray must not render before the gamer occupies a seat
|
||||||
|
room = Room.objects.create(name="Gate Room", owner=self.founder)
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gatekeeper", kwargs={"room_id": room.id})
|
||||||
|
)
|
||||||
|
self.assertNotContains(response, 'id="id_tray_wrap"')
|
||||||
|
|
||||||
def test_six_table_seats_rendered(self):
|
def test_six_table_seats_rendered(self):
|
||||||
self.client.force_login(self.founder)
|
self.client.force_login(self.founder)
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
self.url
|
||||||
)
|
)
|
||||||
self.assertContains(response, "table-seat", count=6)
|
self.assertContains(response, "table-seat", count=6)
|
||||||
|
|
||||||
def test_active_table_seat_has_active_class(self):
|
def test_table_seats_never_active_on_load(self):
|
||||||
self.client.force_login(self.founder) # slot 1 is active
|
# Seat glow is JS-only (during tray animation); never server-rendered
|
||||||
response = self.client.get(
|
|
||||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
||||||
)
|
|
||||||
self.assertContains(response, 'class="table-seat active"')
|
|
||||||
|
|
||||||
def test_inactive_table_seat_lacks_active_class(self):
|
|
||||||
self.client.force_login(self.founder)
|
self.client.force_login(self.founder)
|
||||||
response = self.client.get(
|
response = self.client.get(self.url)
|
||||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
self.assertNotContains(response, 'class="table-seat active"')
|
||||||
)
|
|
||||||
# Slots 2–6 are not active, so at least one plain table-seat exists
|
def test_assigned_seat_renders_role_confirmed_class(self):
|
||||||
self.assertContains(response, 'class="table-seat"')
|
# A seat with a role already picked must load as role-confirmed (opaque chair)
|
||||||
|
self.gamers[0].refresh_from_db()
|
||||||
|
seat = self.room.table_seats.get(slot_number=1)
|
||||||
|
seat.role = "PC"
|
||||||
|
seat.save()
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, 'table-seat role-confirmed')
|
||||||
|
|
||||||
|
def test_unassigned_seat_lacks_role_confirmed_class(self):
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertNotContains(response, 'table-seat role-confirmed')
|
||||||
|
|
||||||
|
def test_assigned_slot_circle_renders_role_assigned_class(self):
|
||||||
|
# Slot 1 circle hidden because 1 role was assigned (count-based, not role-label-based)
|
||||||
|
seat = self.room.table_seats.get(slot_number=1)
|
||||||
|
seat.role = "PC"
|
||||||
|
seat.save()
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, 'gate-slot filled role-assigned')
|
||||||
|
|
||||||
|
def test_slot_circle_hides_by_count_not_role_label(self):
|
||||||
|
# Gamer in slot 1 picks NC (not PC) — slot 1 circle must still hide, not slot 2's
|
||||||
|
seat = self.room.table_seats.get(slot_number=1)
|
||||||
|
seat.role = "NC"
|
||||||
|
seat.save()
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
content = response.content.decode()
|
||||||
|
import re
|
||||||
|
# Template renders class before data-slot; capture both orderings
|
||||||
|
circles = re.findall(r'class="([^"]*gate-slot[^"]*)"[^>]*data-slot="(\d)"', content)
|
||||||
|
slot1_classes = next((cls for cls, slot in circles if slot == "1"), "")
|
||||||
|
slot2_classes = next((cls for cls, slot in circles if slot == "2"), "")
|
||||||
|
self.assertIn("role-assigned", slot1_classes)
|
||||||
|
self.assertNotIn("role-assigned", slot2_classes)
|
||||||
|
|
||||||
|
def test_unassigned_slot_circle_lacks_role_assigned_class(self):
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertNotContains(response, 'role-assigned')
|
||||||
|
|
||||||
|
def test_position_strip_rendered_during_role_select(self):
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, "position-strip")
|
||||||
|
|
||||||
|
def test_position_strip_has_six_gate_slots(self):
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, "gate-slot", count=6)
|
||||||
|
|
||||||
def test_card_stack_has_data_user_slots_for_eligible_gamer(self):
|
def test_card_stack_has_data_user_slots_for_eligible_gamer(self):
|
||||||
self.client.force_login(self.founder) # founder is slot 1 only
|
self.client.force_login(self.founder) # founder is slot 1 only
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
self.url
|
||||||
)
|
)
|
||||||
self.assertContains(response, 'data-user-slots="1"')
|
self.assertContains(response, 'data-user-slots="1"')
|
||||||
|
|
||||||
def test_card_stack_has_data_user_slots_for_ineligible_gamer(self):
|
def test_card_stack_has_data_user_slots_for_ineligible_gamer(self):
|
||||||
self.client.force_login(self.gamers[1]) # slot 2 gamer
|
self.client.force_login(self.gamers[1]) # slot 2 gamer
|
||||||
response = self.client.get(
|
response = self.client.get(
|
||||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
self.url
|
||||||
)
|
)
|
||||||
self.assertContains(response, 'data-user-slots="2"')
|
self.assertContains(response, 'data-user-slots="2"')
|
||||||
|
|
||||||
|
def test_assigned_seat_renders_check_icon(self):
|
||||||
|
seat = self.room.table_seats.get(slot_number=1)
|
||||||
|
seat.role = "PC"
|
||||||
|
seat.save()
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
content = response.content.decode()
|
||||||
|
# The PC seat should have fa-circle-check, not fa-ban
|
||||||
|
pc_seat_start = content.index('data-role="PC"')
|
||||||
|
pc_seat_chunk = content[pc_seat_start:pc_seat_start + 300]
|
||||||
|
self.assertIn("fa-circle-check", pc_seat_chunk)
|
||||||
|
self.assertNotIn("fa-ban", pc_seat_chunk)
|
||||||
|
|
||||||
|
def test_unassigned_seat_renders_ban_icon(self):
|
||||||
|
# slot 2's role is still null
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
content = response.content.decode()
|
||||||
|
nc_seat_start = content.index('data-role="NC"')
|
||||||
|
nc_seat_chunk = content[nc_seat_start:nc_seat_start + 300]
|
||||||
|
self.assertIn("fa-ban", nc_seat_chunk)
|
||||||
|
self.assertNotIn("fa-circle-check", nc_seat_chunk)
|
||||||
|
|
||||||
|
|
||||||
class PickRolesViewTest(TestCase):
|
class PickRolesViewTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@@ -493,7 +583,7 @@ class PickRolesViewTest(TestCase):
|
|||||||
reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
|
reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
|
||||||
)
|
)
|
||||||
self.assertRedirects(
|
self.assertRedirects(
|
||||||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
response, reverse("epic:room", args=[self.room.id])
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_pick_roles_notifies_channel_layer(self):
|
def test_pick_roles_notifies_channel_layer(self):
|
||||||
@@ -554,7 +644,7 @@ class SelectRoleViewTest(TestCase):
|
|||||||
).order_by("slot_number").first()
|
).order_by("slot_number").first()
|
||||||
self.assertEqual(next_active.slot_number, 2)
|
self.assertEqual(next_active.slot_number, 2)
|
||||||
|
|
||||||
def test_all_selected_sets_sig_select(self):
|
def test_all_selected_stays_role_select_status(self):
|
||||||
roles = ["PC", "BC", "SC", "AC", "NC"]
|
roles = ["PC", "BC", "SC", "AC", "NC"]
|
||||||
for i, role in enumerate(roles):
|
for i, role in enumerate(roles):
|
||||||
seat = TableSeat.objects.get(room=self.room, slot_number=i + 1)
|
seat = TableSeat.objects.get(room=self.room, slot_number=i + 1)
|
||||||
@@ -566,7 +656,7 @@ class SelectRoleViewTest(TestCase):
|
|||||||
data={"role": "EC"},
|
data={"role": "EC"},
|
||||||
)
|
)
|
||||||
self.room.refresh_from_db()
|
self.room.refresh_from_db()
|
||||||
self.assertEqual(self.room.table_status, Room.SIG_SELECT)
|
self.assertEqual(self.room.table_status, Room.ROLE_SELECT)
|
||||||
|
|
||||||
def test_select_role_notifies_turn_changed(self):
|
def test_select_role_notifies_turn_changed(self):
|
||||||
with patch("apps.epic.views._notify_turn_changed") as mock_notify:
|
with patch("apps.epic.views._notify_turn_changed") as mock_notify:
|
||||||
@@ -576,14 +666,14 @@ class SelectRoleViewTest(TestCase):
|
|||||||
)
|
)
|
||||||
mock_notify.assert_called_once_with(self.room.id)
|
mock_notify.assert_called_once_with(self.room.id)
|
||||||
|
|
||||||
def test_select_role_notifies_roles_revealed_when_last(self):
|
def test_select_role_notifies_all_roles_filled_when_last(self):
|
||||||
roles = ["PC", "BC", "SC", "AC", "NC"]
|
roles = ["PC", "BC", "SC", "AC", "NC"]
|
||||||
for i, role in enumerate(roles):
|
for i, role in enumerate(roles):
|
||||||
seat = TableSeat.objects.get(room=self.room, slot_number=i + 1)
|
seat = TableSeat.objects.get(room=self.room, slot_number=i + 1)
|
||||||
seat.role = role
|
seat.role = role
|
||||||
seat.save()
|
seat.save()
|
||||||
self.client.force_login(self.gamers[5])
|
self.client.force_login(self.gamers[5])
|
||||||
with patch("apps.epic.views._notify_roles_revealed") as mock_notify:
|
with patch("apps.epic.views._notify_all_roles_filled") as mock_notify:
|
||||||
self.client.post(
|
self.client.post(
|
||||||
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
data={"role": "EC"},
|
data={"role": "EC"},
|
||||||
@@ -631,7 +721,7 @@ class SelectRoleViewTest(TestCase):
|
|||||||
data={"role": "BOGUS"},
|
data={"role": "BOGUS"},
|
||||||
)
|
)
|
||||||
self.assertRedirects(
|
self.assertRedirects(
|
||||||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
response, reverse("epic:room", args=[self.room.id])
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_same_gamer_cannot_double_pick_sequentially(self):
|
def test_same_gamer_cannot_double_pick_sequentially(self):
|
||||||
@@ -646,48 +736,82 @@ class SelectRoleViewTest(TestCase):
|
|||||||
data={"role": "BC"},
|
data={"role": "BC"},
|
||||||
)
|
)
|
||||||
self.assertRedirects(
|
self.assertRedirects(
|
||||||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
response, reverse("epic:room", args=[self.room.id])
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
TableSeat.objects.filter(room=self.room, role__isnull=False).count(), 1
|
TableSeat.objects.filter(room=self.room, role__isnull=False).count(), 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class RevealPhaseRenderingTest(TestCase):
|
class RoomViewAllRolesFilledTest(TestCase):
|
||||||
|
"""Room view in ROLE_SELECT with all seats assigned shows PICK SIGS button."""
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.founder = User.objects.create(email="founder@test.io")
|
import lxml.html
|
||||||
self.room = Room.objects.create(name="Test Room", owner=self.founder)
|
self.lxml = lxml.html
|
||||||
gamers = [self.founder]
|
self.owner = User.objects.create(email="owner@test.io")
|
||||||
for i in range(2, 7):
|
self.room = Room.objects.create(name="Test Room", owner=self.owner)
|
||||||
gamers.append(User.objects.create(email=f"g{i}@test.io"))
|
self.room.table_status = Room.ROLE_SELECT
|
||||||
roles = ["PC", "BC", "SC", "AC", "NC", "EC"]
|
self.room.save()
|
||||||
for i, (gamer, role) in enumerate(zip(gamers, roles), start=1):
|
all_roles = ["PC", "BC", "SC", "AC", "NC", "EC"]
|
||||||
TableSeat.objects.create(
|
for i, role in enumerate(all_roles, start=1):
|
||||||
room=self.room, gamer=gamer, slot_number=i,
|
user = User.objects.create(email=f"p{i}@test.io")
|
||||||
role=role, role_revealed=True,
|
TableSeat.objects.create(room=self.room, gamer=user, slot_number=i, role=role)
|
||||||
)
|
self.client.force_login(self.owner)
|
||||||
self.room.gate_status = Room.OPEN
|
|
||||||
|
def test_pick_sigs_btn_present_when_all_roles_filled(self):
|
||||||
|
response = self.client.get(reverse("epic:room", kwargs={"room_id": self.room.id}))
|
||||||
|
parsed = self.lxml.fromstring(response.content)
|
||||||
|
[_] = parsed.cssselect("#id_pick_sigs_btn")
|
||||||
|
self.assertEqual(parsed.cssselect(".card-stack"), [])
|
||||||
|
|
||||||
|
def test_pick_sigs_btn_hidden_during_role_select(self):
|
||||||
|
# Clear one role — still mid-pick, wrap must be hidden
|
||||||
|
TableSeat.objects.filter(room=self.room, slot_number=6).update(role=None)
|
||||||
|
response = self.client.get(reverse("epic:room", kwargs={"room_id": self.room.id}))
|
||||||
|
parsed = self.lxml.fromstring(response.content)
|
||||||
|
[wrap] = parsed.cssselect("#id_pick_sigs_wrap")
|
||||||
|
self.assertIn("display:none", wrap.get("style", "").replace(" ", ""))
|
||||||
|
|
||||||
|
|
||||||
|
class PickSigsViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.owner = User.objects.create(email="owner@test.io")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.owner)
|
||||||
|
self.room.table_status = Room.ROLE_SELECT
|
||||||
|
self.room.save()
|
||||||
|
all_roles = ["PC", "BC", "SC", "AC", "NC", "EC"]
|
||||||
|
for i, role in enumerate(all_roles, start=1):
|
||||||
|
user = User.objects.create(email=f"p{i}@test.io")
|
||||||
|
TableSeat.objects.create(room=self.room, gamer=user, slot_number=i, role=role)
|
||||||
|
self.client.force_login(self.owner)
|
||||||
|
self.url = reverse("epic:pick_sigs", kwargs={"room_id": self.room.id})
|
||||||
|
|
||||||
|
def test_pick_sigs_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(self.url)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/accounts/login/", response.url)
|
||||||
|
|
||||||
|
def test_pick_sigs_transitions_to_sig_select(self):
|
||||||
|
self.client.post(self.url)
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertEqual(self.room.table_status, Room.SIG_SELECT)
|
||||||
|
|
||||||
|
def test_pick_sigs_redirects_to_room(self):
|
||||||
|
response = self.client.post(self.url)
|
||||||
|
self.assertRedirects(response, reverse("epic:room", args=[self.room.id]))
|
||||||
|
|
||||||
|
def test_pick_sigs_is_noop_if_not_role_select(self):
|
||||||
self.room.table_status = Room.SIG_SELECT
|
self.room.table_status = Room.SIG_SELECT
|
||||||
self.room.save()
|
self.room.save()
|
||||||
self.client.force_login(self.founder)
|
self.client.post(self.url)
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertEqual(self.room.table_status, Room.SIG_SELECT)
|
||||||
|
|
||||||
def test_face_up_role_cards_rendered_when_sig_select(self):
|
def test_pick_sigs_notifies_sig_select_started(self):
|
||||||
response = self.client.get(
|
with patch("apps.epic.views._notify_sig_select_started") as mock_notify:
|
||||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
self.client.post(self.url)
|
||||||
)
|
mock_notify.assert_called_once_with(self.room.id)
|
||||||
self.assertContains(response, "face-up")
|
|
||||||
|
|
||||||
def test_inv_role_card_slot_present(self):
|
|
||||||
response = self.client.get(
|
|
||||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
||||||
)
|
|
||||||
self.assertContains(response, "id_inv_role_card")
|
|
||||||
|
|
||||||
def test_partner_indicator_present_when_sig_select(self):
|
|
||||||
response = self.client.get(
|
|
||||||
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
|
||||||
)
|
|
||||||
self.assertContains(response, "partner-indicator")
|
|
||||||
|
|
||||||
|
|
||||||
class RoomActionsViewTest(TestCase):
|
class RoomActionsViewTest(TestCase):
|
||||||
@@ -766,3 +890,419 @@ class ReleaseSlotViewTest(TestCase):
|
|||||||
)
|
)
|
||||||
self.room.refresh_from_db()
|
self.room.refresh_from_db()
|
||||||
self.assertEqual(self.room.gate_status, Room.GATHERING)
|
self.assertEqual(self.room.gate_status, Room.GATHERING)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Significator Selection ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||||||
|
|
||||||
|
|
||||||
|
def _full_sig_setUp(test_case, role_order=None):
|
||||||
|
"""Populate test_case with a SIG_SELECT room; return (room, gamers, earthman, card_in_deck).
|
||||||
|
Uses get_or_create for DeckVariant — migration data persists in TestCase."""
|
||||||
|
if role_order is None:
|
||||||
|
role_order = SIG_SEAT_ORDER[:]
|
||||||
|
earthman, _ = DeckVariant.objects.get_or_create(
|
||||||
|
slug="earthman",
|
||||||
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||||
|
)
|
||||||
|
founder = User.objects.create(email="founder@test.io")
|
||||||
|
gamers = [founder]
|
||||||
|
for i in range(2, 7):
|
||||||
|
gamers.append(User.objects.create(email=f"g{i}@test.io"))
|
||||||
|
for gamer in gamers:
|
||||||
|
gamer.equipped_deck = earthman
|
||||||
|
gamer.save(update_fields=["equipped_deck"])
|
||||||
|
room = Room.objects.create(name="Sig Room", owner=founder)
|
||||||
|
for i, (gamer, role) in enumerate(zip(gamers, role_order), start=1):
|
||||||
|
slot = room.gate_slots.get(slot_number=i)
|
||||||
|
slot.gamer = gamer
|
||||||
|
slot.status = GateSlot.FILLED
|
||||||
|
slot.save()
|
||||||
|
TableSeat.objects.create(
|
||||||
|
room=room, gamer=gamer, slot_number=i, role=role, role_revealed=True,
|
||||||
|
)
|
||||||
|
room.gate_status = Room.OPEN
|
||||||
|
room.table_status = Room.SIG_SELECT
|
||||||
|
room.save()
|
||||||
|
card_in_deck = TarotCard.objects.get(
|
||||||
|
deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||||||
|
)
|
||||||
|
test_case.client.force_login(founder)
|
||||||
|
return room, gamers, earthman, card_in_deck
|
||||||
|
|
||||||
|
|
||||||
|
class SigSelectRenderingTest(TestCase):
|
||||||
|
"""Gate view at SIG_SELECT renders the Significator deck."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||||||
|
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||||||
|
|
||||||
|
def test_sig_deck_element_present(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, "id_sig_deck")
|
||||||
|
|
||||||
|
def test_sig_deck_contains_18_sig_cards(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.content.decode().count('data-card-id='), 18)
|
||||||
|
|
||||||
|
def test_seats_rendered_in_pc_nc_ec_sc_ac_bc_order(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
content = response.content.decode()
|
||||||
|
positions = {role: content.find(f'data-role="{role}"') for role in SIG_SEAT_ORDER}
|
||||||
|
# Every role must appear
|
||||||
|
self.assertTrue(all(pos != -1 for pos in positions.values()))
|
||||||
|
# Rendered in canonical sequence
|
||||||
|
ordered = sorted(SIG_SEAT_ORDER, key=lambda r: positions[r])
|
||||||
|
self.assertEqual(ordered, SIG_SEAT_ORDER)
|
||||||
|
|
||||||
|
def test_sig_deck_not_present_during_role_select(self):
|
||||||
|
self.room.table_status = Room.ROLE_SELECT
|
||||||
|
self.room.save()
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertNotContains(response, "id_sig_deck")
|
||||||
|
|
||||||
|
def test_sig_cards_render_keyword_data_attributes(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
content = response.content.decode()
|
||||||
|
self.assertIn("data-keywords-upright=", content)
|
||||||
|
self.assertIn("data-keywords-reversed=", content)
|
||||||
|
|
||||||
|
def test_sig_stat_block_structure_rendered(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, "sig-stat-block")
|
||||||
|
self.assertContains(response, "sig-flip-btn")
|
||||||
|
self.assertContains(response, "stat-face--upright")
|
||||||
|
self.assertContains(response, "stat-face--reversed")
|
||||||
|
|
||||||
|
def test_sig_cards_render_cautions_data_attribute(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, "data-cautions=")
|
||||||
|
|
||||||
|
def test_sig_caution_tooltip_structure_rendered(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, "sig-caution-tooltip")
|
||||||
|
self.assertContains(response, "sig-caution-btn")
|
||||||
|
self.assertContains(response, "sig-caution-effect")
|
||||||
|
self.assertContains(response, "sig-caution-index")
|
||||||
|
self.assertContains(response, "sig-caution-prev")
|
||||||
|
self.assertContains(response, "sig-caution-next")
|
||||||
|
|
||||||
|
|
||||||
|
class SelectSigCardViewTest(TestCase):
|
||||||
|
"""select_sig view — records choice, enforces turn order, rejects bad input."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.room, self.gamers, self.earthman, self.card = _full_sig_setUp(self)
|
||||||
|
# Founder is slot 1, role=PC — active first in canonical order
|
||||||
|
self.url = reverse("epic:select_sig", kwargs={"room_id": self.room.id})
|
||||||
|
|
||||||
|
def _post(self, card_id=None, client=None):
|
||||||
|
c = client or self.client
|
||||||
|
return c.post(self.url, data={"card_id": card_id or self.card.id})
|
||||||
|
|
||||||
|
def test_select_sig_records_choice_on_active_seat(self):
|
||||||
|
self._post()
|
||||||
|
seat = TableSeat.objects.get(room=self.room, role="PC")
|
||||||
|
self.assertEqual(seat.significator, self.card)
|
||||||
|
|
||||||
|
def test_select_sig_returns_200(self):
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_select_sig_wrong_turn_makes_no_change(self):
|
||||||
|
# Gamer 2 is NC — not their turn yet
|
||||||
|
self.client.force_login(self.gamers[1])
|
||||||
|
self._post()
|
||||||
|
seat = TableSeat.objects.get(room=self.room, role="NC")
|
||||||
|
self.assertIsNone(seat.significator)
|
||||||
|
|
||||||
|
def test_select_sig_wrong_turn_returns_403(self):
|
||||||
|
self.client.force_login(self.gamers[1])
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_select_sig_card_not_in_deck_returns_400(self):
|
||||||
|
# Create a pip card (number=5) — not in the sig deck (only court 11–14 + major 0–1)
|
||||||
|
other = TarotCard.objects.create(
|
||||||
|
deck_variant=self.earthman, arcana="MINOR", suit="BRANDS", number=5,
|
||||||
|
name="Five of Brands Test", slug="five-of-brands-test",
|
||||||
|
keywords_upright=[], keywords_reversed=[],
|
||||||
|
)
|
||||||
|
response = self._post(card_id=other.id)
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_select_sig_card_already_taken_returns_409(self):
|
||||||
|
# Another seat already holds this card as their significator
|
||||||
|
nc_seat = TableSeat.objects.get(room=self.room, role="NC")
|
||||||
|
nc_seat.significator = self.card
|
||||||
|
nc_seat.save()
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
|
||||||
|
def test_select_sig_advances_active_seat_to_nc(self):
|
||||||
|
self._post()
|
||||||
|
from apps.epic.models import active_sig_seat
|
||||||
|
seat = active_sig_seat(self.room)
|
||||||
|
self.assertEqual(seat.role, "NC")
|
||||||
|
|
||||||
|
def test_select_sig_notifies_ws(self):
|
||||||
|
with patch("apps.epic.views._notify_sig_selected") as mock_notify:
|
||||||
|
self._post()
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
|
||||||
|
def test_select_sig_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/accounts/login/", response.url)
|
||||||
|
|
||||||
|
def test_select_sig_wrong_phase_redirects(self):
|
||||||
|
self.room.table_status = Room.ROLE_SELECT
|
||||||
|
self.room.save()
|
||||||
|
response = self._post()
|
||||||
|
self.assertRedirects(
|
||||||
|
response, reverse("epic:room", args=[self.room.id])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_select_sig_last_choice_does_not_advance_to_none(self):
|
||||||
|
"""After all 6 significators chosen, active_sig_seat() is None —
|
||||||
|
no unhandled AttributeError in the view."""
|
||||||
|
cards = list(TarotCard.objects.filter(deck_variant=self.earthman, arcana="MINOR"))
|
||||||
|
seats_in_order = list(
|
||||||
|
TableSeat.objects.filter(room=self.room).order_by("slot_number")
|
||||||
|
)
|
||||||
|
# Assign all but the last (BC) manually
|
||||||
|
for seat, card in zip(seats_in_order[:-1], cards):
|
||||||
|
seat.significator = card
|
||||||
|
seat.save()
|
||||||
|
# BC gamer POSTs the final choice
|
||||||
|
bc_seat = TableSeat.objects.get(room=self.room, role="BC")
|
||||||
|
self.client.force_login(bc_seat.gamer)
|
||||||
|
last_card = TarotCard.objects.filter(
|
||||||
|
deck_variant=self.earthman, arcana="MAJOR", number=0
|
||||||
|
).first()
|
||||||
|
response = self.client.post(self.url, data={"card_id": last_card.id})
|
||||||
|
self.assertIn(response.status_code, (200, 302))
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmTokenRecordsSlotFilledTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="gamer@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
self.token = Token.objects.create(user=self.user, token_type=Token.TITHE)
|
||||||
|
self.slot = self.room.gate_slots.get(slot_number=1)
|
||||||
|
self.slot.gamer = self.user
|
||||||
|
self.slot.status = GateSlot.RESERVED
|
||||||
|
self.slot.reserved_at = timezone.now()
|
||||||
|
self.slot.save()
|
||||||
|
|
||||||
|
def test_confirm_token_records_slot_filled_event(self):
|
||||||
|
session = self.client.session
|
||||||
|
session["kit_token_id"] = str(self.token.id)
|
||||||
|
session.save()
|
||||||
|
self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
|
||||||
|
event = GameEvent.objects.get(room=self.room, verb=GameEvent.SLOT_FILLED)
|
||||||
|
self.assertEqual(event.actor, self.user)
|
||||||
|
self.assertEqual(event.data["slot_number"], 1)
|
||||||
|
self.assertEqual(event.data["token_type"], Token.TITHE)
|
||||||
|
|
||||||
|
def test_no_event_recorded_if_no_reserved_slot(self):
|
||||||
|
self.slot.gamer = None
|
||||||
|
self.slot.status = GateSlot.EMPTY
|
||||||
|
self.slot.save()
|
||||||
|
self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
|
||||||
|
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.SLOT_FILLED).count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class SelectRoleRecordsRoleSelectedTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="player@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.room = Room.objects.create(
|
||||||
|
name="Role Room", owner=self.user, table_status=Room.ROLE_SELECT
|
||||||
|
)
|
||||||
|
self.seat = TableSeat.objects.create(
|
||||||
|
room=self.room, gamer=self.user, slot_number=1
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_select_role_records_role_selected_event(self):
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", args=[self.room.id]),
|
||||||
|
data={"role": "PC"},
|
||||||
|
)
|
||||||
|
event = GameEvent.objects.get(room=self.room, verb=GameEvent.ROLE_SELECTED)
|
||||||
|
self.assertEqual(event.actor, self.user)
|
||||||
|
self.assertEqual(event.data["role"], "PC")
|
||||||
|
self.assertEqual(event.data["slot_number"], 1)
|
||||||
|
|
||||||
|
def test_no_event_if_role_already_taken(self):
|
||||||
|
TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=2, role="PC")
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", args=[self.room.id]),
|
||||||
|
data={"role": "PC"},
|
||||||
|
)
|
||||||
|
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.ROLE_SELECTED).count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ── sig_reserve view ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SigReserveViewTest(TestCase):
|
||||||
|
"""sig_reserve — provisional card hold; OK/NVM flow."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.room, self.gamers, self.earthman, self.card = _full_sig_setUp(self)
|
||||||
|
# founder (gamers[0]) is PC — levity polarity
|
||||||
|
self.client.force_login(self.gamers[0])
|
||||||
|
self.url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id})
|
||||||
|
|
||||||
|
def _reserve(self, card_id=None, action="reserve", client=None):
|
||||||
|
c = client or self.client
|
||||||
|
return c.post(self.url, data={
|
||||||
|
"card_id": card_id or self.card.id,
|
||||||
|
"action": action,
|
||||||
|
})
|
||||||
|
|
||||||
|
# ── happy-path reserve ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_reserve_creates_sig_reservation(self):
|
||||||
|
self._reserve()
|
||||||
|
self.assertTrue(SigReservation.objects.filter(
|
||||||
|
room=self.room, gamer=self.gamers[0], card=self.card
|
||||||
|
).exists())
|
||||||
|
|
||||||
|
def test_reserve_returns_200(self):
|
||||||
|
response = self._reserve()
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_reservation_has_correct_polarity(self):
|
||||||
|
self._reserve()
|
||||||
|
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
||||||
|
self.assertEqual(res.polarity, "levity")
|
||||||
|
|
||||||
|
def test_gravity_gamer_reservation_has_gravity_polarity(self):
|
||||||
|
# gamers[3] is SC (index 3 → role SC → but _full_sig_setUp uses SIG_SEAT_ORDER
|
||||||
|
# which assigns PC→NC→EC→SC→AC→BC, so slot 4 = SC, slot 5 = AC, slot 6 = BC)
|
||||||
|
# gamers[5] is BC → gravity
|
||||||
|
bc_client = self.client.__class__()
|
||||||
|
bc_client.force_login(self.gamers[5])
|
||||||
|
self._reserve(client=bc_client)
|
||||||
|
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[5])
|
||||||
|
self.assertEqual(res.polarity, "gravity")
|
||||||
|
|
||||||
|
# ── conflict handling ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_reserve_taken_card_same_polarity_returns_409(self):
|
||||||
|
# NC (gamers[1]) reserves the same card first — both are levity
|
||||||
|
nc_client = self.client.__class__()
|
||||||
|
nc_client.force_login(self.gamers[1])
|
||||||
|
self._reserve(client=nc_client)
|
||||||
|
# Now PC tries to grab the same card — should be blocked
|
||||||
|
response = self._reserve()
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
|
||||||
|
def test_reserve_taken_card_cross_polarity_succeeds(self):
|
||||||
|
# BC (gamers[5], gravity) reserves the same card — different polarity, allowed
|
||||||
|
bc_client = self.client.__class__()
|
||||||
|
bc_client.force_login(self.gamers[5])
|
||||||
|
self._reserve(client=bc_client)
|
||||||
|
response = self._reserve() # PC (levity) grabs same card
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_reserve_different_card_while_holding_returns_409(self):
|
||||||
|
"""Cannot OK a different card while holding one — must NVM first."""
|
||||||
|
card_b = TarotCard.objects.filter(
|
||||||
|
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=12
|
||||||
|
).first()
|
||||||
|
self._reserve() # PC grabs card A → 200
|
||||||
|
response = self._reserve(card_id=card_b.id) # tries card B → 409
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
# Original reservation still intact
|
||||||
|
reservations = SigReservation.objects.filter(room=self.room, gamer=self.gamers[0])
|
||||||
|
self.assertEqual(reservations.count(), 1)
|
||||||
|
self.assertEqual(reservations.first().card, self.card)
|
||||||
|
|
||||||
|
def test_reserve_same_card_again_is_idempotent(self):
|
||||||
|
"""Re-POSTing the same card while already holding it returns 200 (no-op)."""
|
||||||
|
self._reserve()
|
||||||
|
response = self._reserve() # same card again
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
SigReservation.objects.filter(room=self.room, gamer=self.gamers[0]).count(), 1
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reserve_blocked_then_unblocked_after_release(self):
|
||||||
|
"""After NVM, a new card can be OK'd."""
|
||||||
|
card_b = TarotCard.objects.filter(
|
||||||
|
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=12
|
||||||
|
).first()
|
||||||
|
self._reserve() # hold card A
|
||||||
|
self._reserve(action="release") # NVM
|
||||||
|
response = self._reserve(card_id=card_b.id) # now card B → 200
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(SigReservation.objects.filter(
|
||||||
|
room=self.room, gamer=self.gamers[0], card=card_b
|
||||||
|
).exists())
|
||||||
|
|
||||||
|
# ── release ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_release_deletes_reservation(self):
|
||||||
|
self._reserve()
|
||||||
|
self._reserve(action="release")
|
||||||
|
self.assertFalse(SigReservation.objects.filter(
|
||||||
|
room=self.room, gamer=self.gamers[0]
|
||||||
|
).exists())
|
||||||
|
|
||||||
|
def test_release_returns_200(self):
|
||||||
|
self._reserve()
|
||||||
|
response = self._reserve(action="release")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_release_with_no_reservation_still_200(self):
|
||||||
|
"""NVM when nothing held is harmless."""
|
||||||
|
response = self._reserve(action="release")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# ── guards ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_reserve_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self._reserve()
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/accounts/login/", response.url)
|
||||||
|
|
||||||
|
def test_reserve_requires_seated_gamer(self):
|
||||||
|
outsider = User.objects.create(email="outsider@test.io")
|
||||||
|
outsider_client = self.client.__class__()
|
||||||
|
outsider_client.force_login(outsider)
|
||||||
|
response = self._reserve(client=outsider_client)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_reserve_wrong_phase_returns_400(self):
|
||||||
|
self.room.table_status = Room.ROLE_SELECT
|
||||||
|
self.room.save()
|
||||||
|
response = self._reserve()
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_reserve_broadcasts_ws(self):
|
||||||
|
with patch("apps.epic.views._notify_sig_reserved") as mock_notify:
|
||||||
|
self._reserve()
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
|
||||||
|
def test_release_broadcasts_ws(self):
|
||||||
|
self._reserve()
|
||||||
|
with patch("apps.epic.views._notify_sig_reserved") as mock_notify:
|
||||||
|
self._reserve(action="release")
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
|
||||||
|
def test_release_broadcasts_card_id_so_second_browser_can_clear_it(self):
|
||||||
|
"""WS release event must include the card_id; otherwise the receiving
|
||||||
|
browser can't find the card element to remove .sig-reserved--own."""
|
||||||
|
self._reserve()
|
||||||
|
with patch("apps.epic.views._notify_sig_reserved") as mock_notify:
|
||||||
|
self._reserve(action="release")
|
||||||
|
args, kwargs = mock_notify.call_args
|
||||||
|
self.assertEqual(args[1], self.card.pk) # card_id must not be None
|
||||||
|
self.assertFalse(kwargs['reserved']) # reserved=False
|
||||||
|
|||||||
@@ -6,13 +6,17 @@ app_name = 'epic'
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('rooms/create_room', views.create_room, name='create_room'),
|
path('rooms/create_room', views.create_room, name='create_room'),
|
||||||
|
path('room/<uuid:room_id>/', views.room_view, name='room'),
|
||||||
path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'),
|
path('room/<uuid:room_id>/gate/', views.gatekeeper, name='gatekeeper'),
|
||||||
path('room/<uuid:room_id>/gate/drop_token', views.drop_token, name='drop_token'),
|
path('room/<uuid:room_id>/gate/drop_token', views.drop_token, name='drop_token'),
|
||||||
path('room/<uuid:room_id>/gate/confirm_token', views.confirm_token, name='confirm_token'),
|
path('room/<uuid:room_id>/gate/confirm_token', views.confirm_token, name='confirm_token'),
|
||||||
path('room/<uuid:room_id>/gate/return_token', views.return_token, name='return_token'),
|
path('room/<uuid:room_id>/gate/return_token', views.return_token, name='return_token'),
|
||||||
path('room/<uuid:room_id>/gate/release_slot', views.release_slot, name='release_slot'),
|
path('room/<uuid:room_id>/gate/release_slot', views.release_slot, name='release_slot'),
|
||||||
path('room/<uuid:room_id>/pick-roles', views.pick_roles, name='pick_roles'),
|
path('room/<uuid:room_id>/pick-roles', views.pick_roles, name='pick_roles'),
|
||||||
|
path('room/<uuid:room_id>/pick-sigs', views.pick_sigs, name='pick_sigs'),
|
||||||
path('room/<uuid:room_id>/select-role', views.select_role, name='select_role'),
|
path('room/<uuid:room_id>/select-role', views.select_role, name='select_role'),
|
||||||
|
path('room/<uuid:room_id>/select-sig', views.select_sig, name='select_sig'),
|
||||||
|
path('room/<uuid:room_id>/sig-reserve', views.sig_reserve, name='sig_reserve'),
|
||||||
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
|
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
|
||||||
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
|
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
|
||||||
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
@@ -9,9 +10,13 @@ from django.shortcuts import redirect, render
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from apps.drama.models import GameEvent, record
|
from apps.drama.models import GameEvent, record
|
||||||
|
from django.db.models import Case, IntegerField, Value, When
|
||||||
|
|
||||||
from apps.epic.models import (
|
from apps.epic.models import (
|
||||||
GateSlot, Room, RoomInvite, TableSeat, TarotDeck,
|
GateSlot, Room, RoomInvite, SIG_SEAT_ORDER, SigReservation, TableSeat,
|
||||||
debit_token, select_token,
|
TarotCard, TarotDeck,
|
||||||
|
active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards,
|
||||||
|
select_token, sig_deck_cards,
|
||||||
)
|
)
|
||||||
from apps.lyric.models import Token
|
from apps.lyric.models import Token
|
||||||
|
|
||||||
@@ -41,14 +46,17 @@ def _notify_turn_changed(room_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _notify_roles_revealed(room_id):
|
def _notify_all_roles_filled(room_id):
|
||||||
assignments = {
|
|
||||||
str(seat.slot_number): seat.role
|
|
||||||
for seat in TableSeat.objects.filter(room_id=room_id).order_by("slot_number")
|
|
||||||
}
|
|
||||||
async_to_sync(get_channel_layer().group_send)(
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
f'room_{room_id}',
|
f'room_{room_id}',
|
||||||
{'type': 'roles_revealed', 'assignments': assignments},
|
{'type': 'all_roles_filled'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_sig_select_started(room_id):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'room_{room_id}',
|
||||||
|
{'type': 'sig_select_started'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -64,6 +72,66 @@ def _notify_role_select_start(room_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_sig_selected(room_id, card_id, role, deck_type='levity'):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'room_{room_id}',
|
||||||
|
{'type': 'sig_selected', 'card_id': str(card_id), 'role': role, 'deck_type': deck_type},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_LEVITY_ROLES = {'PC', 'NC', 'SC'}
|
||||||
|
_GRAVITY_ROLES = {'BC', 'EC', 'AC'}
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_sig_reserved(room_id, card_id, role, reserved):
|
||||||
|
"""Broadcast a sig_reserved event to the matching polarity cursor group."""
|
||||||
|
polarity = 'levity' if role in _LEVITY_ROLES else 'gravity'
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'cursors_{room_id}_{polarity}',
|
||||||
|
{'type': 'sig_reserved', 'card_id': str(card_id) if card_id else None,
|
||||||
|
'role': role, 'reserved': reserved},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
||||||
|
|
||||||
|
_SIG_SEAT_ORDERING = Case(
|
||||||
|
*[When(role=r, then=Value(i)) for i, r in enumerate(SIG_SEAT_ORDER)],
|
||||||
|
default=Value(99),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_user_seat(room, user):
|
||||||
|
"""Return the user's seat whose role comes first in PC→NC→EC→SC→AC→BC order.
|
||||||
|
|
||||||
|
In normal play (one user = one seat) this is equivalent to .first().
|
||||||
|
For Carte Blanche (one user = all seats) it returns the PC seat, ensuring
|
||||||
|
sig-select cursor placement is seat-based, not position/slot-based.
|
||||||
|
"""
|
||||||
|
return room.table_seats.filter(gamer=user).order_by(_SIG_SEAT_ORDERING).first()
|
||||||
|
|
||||||
|
_ROLE_SCRAWL_NAMES = {
|
||||||
|
"PC": "Player", "NC": "Narrator", "EC": "Economist",
|
||||||
|
"SC": "Shepherd", "AC": "Alchemist", "BC": "Builder",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _gate_positions(room):
|
||||||
|
"""Return list of dicts [{slot, role_label, role_assigned}] for _table_positions.html."""
|
||||||
|
# Circles disappear in turn order (slot 1 first, slot 2 second, …) regardless
|
||||||
|
# of which role each gamer chose — so use count, not role matching.
|
||||||
|
assigned_count = room.table_seats.exclude(role__isnull=True).count()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"slot": slot,
|
||||||
|
"role_label": SLOT_ROLE_LABELS.get(slot.slot_number, ""),
|
||||||
|
"role_assigned": slot.slot_number <= assigned_count,
|
||||||
|
}
|
||||||
|
for slot in room.gate_slots.order_by("slot_number")
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _expire_reserved_slots(room):
|
def _expire_reserved_slots(room):
|
||||||
cutoff = timezone.now() - RESERVE_TIMEOUT
|
cutoff = timezone.now() - RESERVE_TIMEOUT
|
||||||
room.gate_slots.filter(
|
room.gate_slots.filter(
|
||||||
@@ -128,6 +196,8 @@ def _gate_context(room, user):
|
|||||||
"carte_slots_claimed": carte_slots_claimed,
|
"carte_slots_claimed": carte_slots_claimed,
|
||||||
"carte_nvm_slot_number": carte_nvm_slot_number,
|
"carte_nvm_slot_number": carte_nvm_slot_number,
|
||||||
"carte_next_slot_number": carte_next_slot_number,
|
"carte_next_slot_number": carte_next_slot_number,
|
||||||
|
"gate_positions": _gate_positions(room),
|
||||||
|
"starter_roles": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -159,6 +229,8 @@ def _role_select_context(room, user):
|
|||||||
starter_roles = list(
|
starter_roles = list(
|
||||||
room.table_seats.exclude(role__isnull=True).values_list("role", flat=True)
|
room.table_seats.exclude(role__isnull=True).values_list("role", flat=True)
|
||||||
)
|
)
|
||||||
|
if len(starter_roles) == 6:
|
||||||
|
card_stack_state = None
|
||||||
_action_order = {r: i for i, r in enumerate(["PC", "NC", "EC", "SC", "AC", "BC"])}
|
_action_order = {r: i for i, r in enumerate(["PC", "NC", "EC", "SC", "AC", "BC"])}
|
||||||
assigned_seats = (
|
assigned_seats = (
|
||||||
sorted(
|
sorted(
|
||||||
@@ -168,10 +240,16 @@ def _role_select_context(room, user):
|
|||||||
if user.is_authenticated else []
|
if user.is_authenticated else []
|
||||||
)
|
)
|
||||||
active_slot = active_seat.slot_number if active_seat else None
|
active_slot = active_seat.slot_number if active_seat else None
|
||||||
|
_my_role = assigned_seats[0].role if assigned_seats else None
|
||||||
ctx = {
|
ctx = {
|
||||||
"card_stack_state": card_stack_state,
|
"card_stack_state": card_stack_state,
|
||||||
"starter_roles": starter_roles,
|
"starter_roles": starter_roles,
|
||||||
"assigned_seats": assigned_seats,
|
"assigned_seats": assigned_seats,
|
||||||
|
"my_tray_role": _my_role,
|
||||||
|
"my_tray_scrawl_static_path": (
|
||||||
|
f"apps/epic/icons/cards-roles/starter-role-{_ROLE_SCRAWL_NAMES[_my_role]}.svg"
|
||||||
|
if _my_role else None
|
||||||
|
),
|
||||||
"user_seat": user_seat,
|
"user_seat": user_seat,
|
||||||
"user_slots": list(
|
"user_slots": list(
|
||||||
room.table_seats.filter(gamer=user, role__isnull=True)
|
room.table_seats.filter(gamer=user, role__isnull=True)
|
||||||
@@ -179,14 +257,40 @@ def _role_select_context(room, user):
|
|||||||
.values_list("slot_number", flat=True)
|
.values_list("slot_number", flat=True)
|
||||||
) if user.is_authenticated else [],
|
) if user.is_authenticated else [],
|
||||||
"active_slot": active_slot,
|
"active_slot": active_slot,
|
||||||
|
"gate_positions": _gate_positions(room),
|
||||||
|
"slots": room.gate_slots.order_by("slot_number"),
|
||||||
}
|
}
|
||||||
if room.table_status == Room.SIG_SELECT:
|
if room.table_status == Room.SIG_SELECT:
|
||||||
user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None
|
user_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
|
||||||
partner_role = TableSeat.PARTNER_MAP.get(user_seat.role) if user_seat and user_seat.role else None
|
user_role = user_seat.role if user_seat else None
|
||||||
partner_seat = room.table_seats.filter(role=partner_role).first() if partner_role else None
|
user_polarity = None
|
||||||
|
if user_role in _LEVITY_ROLES:
|
||||||
|
user_polarity = 'levity'
|
||||||
|
elif user_role in _GRAVITY_ROLES:
|
||||||
|
user_polarity = 'gravity'
|
||||||
|
|
||||||
ctx["user_seat"] = user_seat
|
ctx["user_seat"] = user_seat
|
||||||
ctx["partner_seat"] = partner_seat
|
ctx["user_polarity"] = user_polarity
|
||||||
ctx["revealed_seats"] = room.table_seats.filter(role_revealed=True).order_by("slot_number")
|
ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve"
|
||||||
|
|
||||||
|
# Pre-load existing reservations for this polarity so JS can restore
|
||||||
|
# grabbed state on page load/refresh. Keyed by str(card_id) → role.
|
||||||
|
if user_polarity:
|
||||||
|
polarity_const = SigReservation.LEVITY if user_polarity == 'levity' else SigReservation.GRAVITY
|
||||||
|
reservations = {
|
||||||
|
str(res.card_id): res.role
|
||||||
|
for res in room.sig_reservations.filter(polarity=polarity_const)
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
reservations = {}
|
||||||
|
ctx["sig_reservations_json"] = json.dumps(reservations)
|
||||||
|
|
||||||
|
if user_polarity == 'levity':
|
||||||
|
ctx["sig_cards"] = levity_sig_cards(room)
|
||||||
|
elif user_polarity == 'gravity':
|
||||||
|
ctx["sig_cards"] = gravity_sig_cards(room)
|
||||||
|
else:
|
||||||
|
ctx["sig_cards"] = []
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
@@ -203,10 +307,18 @@ def create_room(request):
|
|||||||
def gatekeeper(request, room_id):
|
def gatekeeper(request, room_id):
|
||||||
room = Room.objects.get(id=room_id)
|
room = Room.objects.get(id=room_id)
|
||||||
if room.table_status:
|
if room.table_status:
|
||||||
ctx = _role_select_context(room, request.user)
|
return redirect("epic:room", room_id=room_id)
|
||||||
else:
|
ctx = _gate_context(room, request.user)
|
||||||
ctx = _gate_context(room, request.user)
|
|
||||||
ctx["room"] = room
|
ctx["room"] = room
|
||||||
|
ctx["page_class"] = "page-gameboard"
|
||||||
|
return render(request, "apps/gameboard/room.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def room_view(request, room_id):
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
ctx = _role_select_context(room, request.user)
|
||||||
|
ctx["room"] = room
|
||||||
|
ctx["page_class"] = "page-gameboard"
|
||||||
return render(request, "apps/gameboard/room.html", ctx)
|
return render(request, "apps/gameboard/room.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@@ -377,17 +489,20 @@ def select_role(request, room_id):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
room = Room.objects.get(id=room_id)
|
room = Room.objects.get(id=room_id)
|
||||||
if room.table_status != Room.ROLE_SELECT:
|
if room.table_status != Room.ROLE_SELECT:
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect(
|
||||||
|
"epic:room" if room.table_status else "epic:gatekeeper",
|
||||||
|
room_id=room_id,
|
||||||
|
)
|
||||||
role = request.POST.get("role")
|
role = request.POST.get("role")
|
||||||
valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES]
|
valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES]
|
||||||
if not role or role not in valid_roles:
|
if not role or role not in valid_roles:
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:room", room_id=room_id)
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
active_seat = room.table_seats.select_for_update().filter(
|
active_seat = room.table_seats.select_for_update().filter(
|
||||||
role__isnull=True
|
role__isnull=True
|
||||||
).order_by("slot_number").first()
|
).order_by("slot_number").first()
|
||||||
if not active_seat or active_seat.gamer != request.user:
|
if not active_seat or active_seat.gamer != request.user:
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:room", room_id=room_id)
|
||||||
if room.table_seats.filter(role=role).exists():
|
if room.table_seats.filter(role=role).exists():
|
||||||
return HttpResponse(status=409)
|
return HttpResponse(status=409)
|
||||||
active_seat.role = role
|
active_seat.role = role
|
||||||
@@ -398,12 +513,20 @@ def select_role(request, room_id):
|
|||||||
if room.table_seats.filter(role__isnull=True).exists():
|
if room.table_seats.filter(role__isnull=True).exists():
|
||||||
_notify_turn_changed(room_id)
|
_notify_turn_changed(room_id)
|
||||||
else:
|
else:
|
||||||
|
_notify_all_roles_filled(room_id)
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
return redirect("epic:room", room_id=room_id)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def pick_sigs(request, room_id):
|
||||||
|
if request.method == "POST":
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
if room.table_status == Room.ROLE_SELECT:
|
||||||
room.table_status = Room.SIG_SELECT
|
room.table_status = Room.SIG_SELECT
|
||||||
room.save()
|
room.save()
|
||||||
record(room, GameEvent.ROLES_REVEALED)
|
_notify_sig_select_started(room_id)
|
||||||
_notify_roles_revealed(room_id)
|
return redirect("epic:room", room_id=room_id)
|
||||||
return HttpResponse(status=200)
|
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -420,7 +543,7 @@ def pick_roles(request, room_id):
|
|||||||
slot_number=slot.slot_number,
|
slot_number=slot.slot_number,
|
||||||
)
|
)
|
||||||
_notify_role_select_start(room_id)
|
_notify_role_select_start(room_id)
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:room", room_id=room_id)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -468,6 +591,92 @@ def gate_status(request, room_id):
|
|||||||
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)
|
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def sig_reserve(request, room_id):
|
||||||
|
"""Provisional card hold (OK / NVM) during SIG_SELECT.
|
||||||
|
POST body: card_id=<uuid>, action=reserve|release
|
||||||
|
"""
|
||||||
|
if request.method != "POST":
|
||||||
|
return HttpResponse(status=405)
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
if room.table_status != Room.SIG_SELECT:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
user_seat = _canonical_user_seat(room, request.user)
|
||||||
|
if not user_seat or not user_seat.role:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
|
action = request.POST.get("action", "reserve")
|
||||||
|
|
||||||
|
if action == "release":
|
||||||
|
existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
|
||||||
|
released_card_id = existing.card_id if existing else None
|
||||||
|
SigReservation.objects.filter(room=room, gamer=request.user).delete()
|
||||||
|
_notify_sig_reserved(room_id, released_card_id, user_seat.role, reserved=False)
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
# Reserve action
|
||||||
|
card_id = request.POST.get("card_id")
|
||||||
|
try:
|
||||||
|
card = TarotCard.objects.get(pk=card_id)
|
||||||
|
except TarotCard.DoesNotExist:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
polarity = SigReservation.LEVITY if user_seat.role in _LEVITY_ROLES else SigReservation.GRAVITY
|
||||||
|
|
||||||
|
# Block if another gamer in the same polarity already holds this card
|
||||||
|
if SigReservation.objects.filter(
|
||||||
|
room=room, card=card, polarity=polarity
|
||||||
|
).exclude(gamer=request.user).exists():
|
||||||
|
return HttpResponse(status=409)
|
||||||
|
|
||||||
|
# Block if this gamer already holds a *different* card — must NVM first
|
||||||
|
existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
|
||||||
|
if existing and existing.card != card:
|
||||||
|
return HttpResponse(status=409)
|
||||||
|
|
||||||
|
# Idempotent: already holding the same card
|
||||||
|
if existing:
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
SigReservation.objects.create(
|
||||||
|
room=room, gamer=request.user, card=card,
|
||||||
|
seat=user_seat, role=user_seat.role, polarity=polarity,
|
||||||
|
)
|
||||||
|
_notify_sig_reserved(room_id, card.pk, user_seat.role, reserved=True)
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def select_sig(request, room_id):
|
||||||
|
if request.method != "POST":
|
||||||
|
return redirect("epic:gatekeeper", room_id=room_id)
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
if room.table_status != Room.SIG_SELECT:
|
||||||
|
return redirect(
|
||||||
|
"epic:room" if room.table_status else "epic:gatekeeper",
|
||||||
|
room_id=room_id,
|
||||||
|
)
|
||||||
|
active_seat = active_sig_seat(room)
|
||||||
|
if active_seat is None or active_seat.gamer != request.user:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
card_id = request.POST.get("card_id")
|
||||||
|
try:
|
||||||
|
card = TarotCard.objects.get(pk=card_id)
|
||||||
|
except TarotCard.DoesNotExist:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
sig_card_ids = {c.pk for c in sig_deck_cards(room)}
|
||||||
|
if card.pk not in sig_card_ids:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
if room.table_seats.filter(significator=card).exists():
|
||||||
|
return HttpResponse(status=409)
|
||||||
|
active_seat.significator = card
|
||||||
|
active_seat.save()
|
||||||
|
deck_type = request.POST.get('deck_type', 'levity')
|
||||||
|
_notify_sig_selected(room_id, card.pk, active_seat.role, deck_type)
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def tarot_deck(request, room_id):
|
def tarot_deck(request, room_id):
|
||||||
room = Room.objects.get(id=room_id)
|
room = Room.objects.get(id=room_id)
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ function initGameKitPage() {
|
|||||||
fanContent.innerHTML = html;
|
fanContent.innerHTML = html;
|
||||||
cards = Array.from(fanContent.querySelectorAll('.fan-card'));
|
cards = Array.from(fanContent.querySelectorAll('.fan-card'));
|
||||||
if (currentIndex >= cards.length) currentIndex = 0;
|
if (currentIndex >= cards.length) currentIndex = 0;
|
||||||
|
cards.forEach(function(c) {
|
||||||
|
c.style.transition = 'transform 0.18s ease-out, opacity 0.18s ease-out';
|
||||||
|
});
|
||||||
updateFan();
|
updateFan();
|
||||||
dialog.showModal();
|
dialog.showModal();
|
||||||
});
|
});
|
||||||
@@ -84,6 +87,21 @@ function initGameKitPage() {
|
|||||||
updateFan();
|
updateFan();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Step through multiple cards one at a time so intermediate cards are visible
|
||||||
|
var _navTimer = null;
|
||||||
|
function navigateAnimated(steps) {
|
||||||
|
if (!cards.length || steps === 0) return;
|
||||||
|
clearTimeout(_navTimer);
|
||||||
|
var sign = steps > 0 ? 1 : -1;
|
||||||
|
var remaining = Math.abs(steps);
|
||||||
|
function tick() {
|
||||||
|
navigate(sign);
|
||||||
|
remaining--;
|
||||||
|
if (remaining > 0) _navTimer = setTimeout(tick, 60);
|
||||||
|
}
|
||||||
|
tick();
|
||||||
|
}
|
||||||
|
|
||||||
// Click on the dark backdrop (the dialog or fan-wrap itself, not on any card child) closes
|
// Click on the dark backdrop (the dialog or fan-wrap itself, not on any card child) closes
|
||||||
var fanWrap = dialog.querySelector('.tarot-fan-wrap');
|
var fanWrap = dialog.querySelector('.tarot-fan-wrap');
|
||||||
dialog.addEventListener('click', function(e) {
|
dialog.addEventListener('click', function(e) {
|
||||||
@@ -96,16 +114,46 @@ function initGameKitPage() {
|
|||||||
if (e.key === 'ArrowLeft') navigate(-1);
|
if (e.key === 'ArrowLeft') navigate(-1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mousewheel navigation — throttled so each detent advances one card
|
// Mousewheel navigation — accumulate delta, cap at 3 cards per event so fast
|
||||||
var lastWheel = 0;
|
// spins don't overshoot; CSS transitions handle the visual smoothness.
|
||||||
|
var wheelAccum = 0;
|
||||||
|
var wheelDecayTimer = null;
|
||||||
|
var WHEEL_STEP = 150;
|
||||||
dialog.addEventListener('wheel', function(e) {
|
dialog.addEventListener('wheel', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var now = Date.now();
|
clearTimeout(wheelDecayTimer);
|
||||||
if (now - lastWheel < 150) return;
|
wheelAccum += e.deltaY;
|
||||||
lastWheel = now;
|
var steps = Math.trunc(wheelAccum / WHEEL_STEP);
|
||||||
navigate(e.deltaY > 0 ? 1 : -1);
|
if (steps !== 0) {
|
||||||
|
steps = Math.sign(steps) * Math.min(Math.abs(steps), 3);
|
||||||
|
wheelAccum -= steps * WHEEL_STEP;
|
||||||
|
navigate(steps);
|
||||||
|
}
|
||||||
|
wheelDecayTimer = setTimeout(function() { wheelAccum = 0; }, 200);
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
|
|
||||||
|
// Touch/swipe navigation — uses navigateAnimated so intermediate cards are visible
|
||||||
|
var touchStartX = 0;
|
||||||
|
var touchStartY = 0;
|
||||||
|
var touchStartTime = 0;
|
||||||
|
dialog.addEventListener('touchstart', function(e) {
|
||||||
|
touchStartX = e.touches[0].clientX;
|
||||||
|
touchStartY = e.touches[0].clientY;
|
||||||
|
touchStartTime = Date.now();
|
||||||
|
}, { passive: true });
|
||||||
|
dialog.addEventListener('touchend', function(e) {
|
||||||
|
var dx = e.changedTouches[0].clientX - touchStartX;
|
||||||
|
var dy = e.changedTouches[0].clientY - touchStartY;
|
||||||
|
if (Math.abs(dy) > Math.abs(dx)) return; // vertical swipe — ignore
|
||||||
|
if (Math.abs(dx) < 60) return; // dead zone — raise to 40–60 for more deliberate swipe required
|
||||||
|
var elapsed = Math.max(1, Date.now() - touchStartTime);
|
||||||
|
var velocity = Math.abs(dx) / elapsed; // px/ms
|
||||||
|
var steps = velocity > 0.8 // flick threshold — raise (e.g. 0.6) so more swipes use drag formula
|
||||||
|
? Math.max(1, Math.round(velocity * 4)) // flick multiplier — lower (e.g. 4–5) to reduce cards per fast flick
|
||||||
|
: Math.round(Math.abs(dx) / 150); // slow-drag divisor — raise (e.g. 120–150) for fewer cards per short drag
|
||||||
|
navigateAnimated(dx < 0 ? steps : -steps);
|
||||||
|
}, { passive: true });
|
||||||
|
|
||||||
prevBtn.addEventListener('click', function() { navigate(-1); });
|
prevBtn.addEventListener('click', function() { navigate(-1); });
|
||||||
nextBtn.addEventListener('click', function() { navigate(1); });
|
nextBtn.addEventListener('click', function() { navigate(1); });
|
||||||
|
|
||||||
|
|||||||
@@ -139,8 +139,18 @@ function initGameKitTooltips() {
|
|||||||
const rawLeft = tokenRect.left + tokenRect.width / 2;
|
const rawLeft = tokenRect.left + tokenRect.width / 2;
|
||||||
const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8));
|
const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8));
|
||||||
portal.style.left = Math.round(clampedLeft) + 'px';
|
portal.style.left = Math.round(clampedLeft) + 'px';
|
||||||
portal.style.top = Math.round(tokenRect.top) + 'px';
|
|
||||||
portal.style.transform = `translate(-50%, calc(-100% - 0.5rem - ${miniHeight}px))`;
|
// Show above when token is in lower viewport half; below when in upper half
|
||||||
|
// (avoids clipping when game-kit tokens sit near the top in landscape mode).
|
||||||
|
const tokenCenterY = tokenRect.top + tokenRect.height / 2;
|
||||||
|
const showBelow = tokenCenterY < window.innerHeight / 2;
|
||||||
|
if (showBelow) {
|
||||||
|
portal.style.top = Math.round(tokenRect.bottom) + 'px';
|
||||||
|
portal.style.transform = 'translate(-50%, 0.5rem)';
|
||||||
|
} else {
|
||||||
|
portal.style.top = Math.round(tokenRect.top) + 'px';
|
||||||
|
portal.style.transform = `translate(-50%, calc(-100% - 0.5rem - ${miniHeight}px))`;
|
||||||
|
}
|
||||||
|
|
||||||
if (isEquippable) {
|
if (isEquippable) {
|
||||||
const mainRect = portal.getBoundingClientRect();
|
const mainRect = portal.getBoundingClientRect();
|
||||||
|
|||||||
@@ -106,6 +106,110 @@ class ToggleGameAppletsViewTest(TestCase):
|
|||||||
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=dash_applet).exists())
|
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=dash_applet).exists())
|
||||||
|
|
||||||
|
|
||||||
|
class GameKitViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="gamer@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
Applet.objects.get_or_create(slug="gk-trinkets", defaults={"name": "Trinkets", "context": "game-kit"})
|
||||||
|
Applet.objects.get_or_create(slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"})
|
||||||
|
Applet.objects.get_or_create(slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"})
|
||||||
|
Applet.objects.get_or_create(slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"})
|
||||||
|
response = self.client.get("/gameboard/game-kit/")
|
||||||
|
self.parsed = lxml.html.fromstring(response.content)
|
||||||
|
|
||||||
|
def test_game_kit_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get("/gameboard/game-kit/")
|
||||||
|
self.assertRedirects(response, "/?next=/gameboard/game-kit/", fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_game_kit_shows_gear_btn(self):
|
||||||
|
[_] = self.parsed.cssselect(".gear-btn")
|
||||||
|
|
||||||
|
def test_game_kit_shows_applet_menu(self):
|
||||||
|
[_] = self.parsed.cssselect("#id_game_kit_menu")
|
||||||
|
|
||||||
|
def test_game_kit_applet_menu_has_trinkets_checkbox(self):
|
||||||
|
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-trinkets']")
|
||||||
|
self.assertEqual(inp.get("type"), "checkbox")
|
||||||
|
|
||||||
|
def test_game_kit_applet_menu_has_tokens_checkbox(self):
|
||||||
|
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-tokens']")
|
||||||
|
self.assertEqual(inp.get("type"), "checkbox")
|
||||||
|
|
||||||
|
def test_game_kit_applet_menu_has_decks_checkbox(self):
|
||||||
|
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-decks']")
|
||||||
|
self.assertEqual(inp.get("type"), "checkbox")
|
||||||
|
|
||||||
|
def test_game_kit_applet_menu_has_dice_checkbox(self):
|
||||||
|
[inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-dice']")
|
||||||
|
self.assertEqual(inp.get("type"), "checkbox")
|
||||||
|
|
||||||
|
def test_game_kit_sections_container_present(self):
|
||||||
|
[_] = self.parsed.cssselect("#id_gk_sections_container")
|
||||||
|
|
||||||
|
def test_all_sections_visible_by_default(self):
|
||||||
|
sections = self.parsed.cssselect("#id_gk_sections_container section")
|
||||||
|
self.assertEqual(len(sections), 4)
|
||||||
|
|
||||||
|
|
||||||
|
class ToggleGameKitSectionsViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="gamer@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.trinkets, _ = Applet.objects.get_or_create(
|
||||||
|
slug="gk-trinkets", defaults={"name": "Trinkets", "context": "game-kit"}
|
||||||
|
)
|
||||||
|
self.tokens, _ = Applet.objects.get_or_create(
|
||||||
|
slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"}
|
||||||
|
)
|
||||||
|
self.decks, _ = Applet.objects.get_or_create(
|
||||||
|
slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"}
|
||||||
|
)
|
||||||
|
self.dice, _ = Applet.objects.get_or_create(
|
||||||
|
slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"}
|
||||||
|
)
|
||||||
|
self.url = reverse("toggle_game_kit_sections")
|
||||||
|
|
||||||
|
def test_unauthenticated_user_is_redirected(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(self.url)
|
||||||
|
self.assertRedirects(response, f"/?next={self.url}", fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_unchecked_section_gets_user_applet_with_visible_false(self):
|
||||||
|
self.client.post(self.url, {"applets": ["gk-trinkets"]})
|
||||||
|
ua = UserApplet.objects.get(user=self.user, applet=self.tokens)
|
||||||
|
self.assertFalse(ua.visible)
|
||||||
|
|
||||||
|
def test_redirects_on_normal_post(self):
|
||||||
|
response = self.client.post(self.url, {"applets": ["gk-trinkets", "gk-tokens"]})
|
||||||
|
self.assertRedirects(response, reverse("game_kit"), fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_returns_200_on_htmx_post(self):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{"applets": ["gk-trinkets", "gk-tokens"]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_does_not_affect_gameboard_applets(self):
|
||||||
|
gb_applet, _ = Applet.objects.get_or_create(
|
||||||
|
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
self.client.post(self.url, {"applets": ["gk-trinkets"]})
|
||||||
|
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=gb_applet).exists())
|
||||||
|
|
||||||
|
def test_hidden_section_absent_from_htmx_response(self):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{"applets": ["gk-trinkets"]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
sections = parsed.cssselect("section")
|
||||||
|
self.assertEqual(len(sections), 1)
|
||||||
|
|
||||||
|
|
||||||
class EquipTrinketViewTest(TestCase):
|
class EquipTrinketViewTest(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create(email="gamer@test.io")
|
self.user = User.objects.create(email="gamer@test.io")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ urlpatterns = [
|
|||||||
path('equip-trinket/<int:token_id>/', views.equip_trinket, name='equip_trinket'),
|
path('equip-trinket/<int:token_id>/', views.equip_trinket, name='equip_trinket'),
|
||||||
path('equip-deck/<int:deck_id>/', views.equip_deck, name='equip_deck'),
|
path('equip-deck/<int:deck_id>/', views.equip_deck, name='equip_deck'),
|
||||||
path('game-kit/', views.game_kit, name='game_kit'),
|
path('game-kit/', views.game_kit, name='game_kit'),
|
||||||
|
path('game-kit/toggle-sections', views.toggle_game_kit_sections, name='toggle_game_kit_sections'),
|
||||||
path('game-kit/deck/<int:deck_id>/', views.tarot_fan, name='tarot_fan'),
|
path('game-kit/deck/<int:deck_id>/', views.tarot_fan, name='tarot_fan'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -102,33 +102,55 @@ def equip_deck(request, deck_id):
|
|||||||
return HttpResponse(status=405)
|
return HttpResponse(status=405)
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url="/")
|
def _game_kit_context(user):
|
||||||
def game_kit(request):
|
coin = user.tokens.filter(token_type=Token.COIN).first()
|
||||||
coin = request.user.tokens.filter(token_type=Token.COIN).first()
|
pass_token = user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None
|
||||||
pass_token = request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None
|
carte = user.tokens.filter(token_type=Token.CARTE).first()
|
||||||
carte = request.user.tokens.filter(token_type=Token.CARTE).first()
|
free_tokens = list(user.tokens.filter(
|
||||||
free_tokens = list(request.user.tokens.filter(
|
|
||||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||||
).order_by("expires_at"))
|
).order_by("expires_at"))
|
||||||
tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE))
|
tithe_tokens = list(user.tokens.filter(token_type=Token.TITHE))
|
||||||
return render(request, "apps/gameboard/game_kit.html", {
|
return {
|
||||||
"coin": coin,
|
"coin": coin,
|
||||||
"pass_token": pass_token,
|
"pass_token": pass_token,
|
||||||
"carte": carte,
|
"carte": carte,
|
||||||
"free_tokens": free_tokens,
|
"free_tokens": free_tokens,
|
||||||
"tithe_tokens": tithe_tokens,
|
"tithe_tokens": tithe_tokens,
|
||||||
"unlocked_decks": list(request.user.unlocked_decks.all()),
|
"unlocked_decks": list(user.unlocked_decks.all()),
|
||||||
|
"applets": applet_context(user, "game-kit"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def game_kit(request):
|
||||||
|
return render(request, "apps/gameboard/game_kit.html", {
|
||||||
|
**_game_kit_context(request.user),
|
||||||
"page_class": "page-gameboard",
|
"page_class": "page-gameboard",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def toggle_game_kit_sections(request):
|
||||||
|
checked = request.POST.getlist("applets")
|
||||||
|
for applet in Applet.objects.filter(context="game-kit"):
|
||||||
|
UserApplet.objects.update_or_create(
|
||||||
|
user=request.user,
|
||||||
|
applet=applet,
|
||||||
|
defaults={"visible": applet.slug in checked},
|
||||||
|
)
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return render(request, "apps/gameboard/_partials/_game_kit_sections.html",
|
||||||
|
_game_kit_context(request.user))
|
||||||
|
return redirect("game_kit")
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
def tarot_fan(request, deck_id):
|
def tarot_fan(request, deck_id):
|
||||||
from apps.epic.models import TarotCard
|
from apps.epic.models import TarotCard
|
||||||
deck = get_object_or_404(DeckVariant, pk=deck_id)
|
deck = get_object_or_404(DeckVariant, pk=deck_id)
|
||||||
if not request.user.unlocked_decks.filter(pk=deck_id).exists():
|
if not request.user.unlocked_decks.filter(pk=deck_id).exists():
|
||||||
return HttpResponse(status=403)
|
return HttpResponse(status=403)
|
||||||
_suit_order = {"WANDS": 0, "CUPS": 1, "SWORDS": 2, "PENTACLES": 3, "COINS": 4}
|
_suit_order = {"WANDS": 0, "CUPS": 1, "SWORDS": 2, "PENTACLES": 3, "CROWNS": 3, "COINS": 4}
|
||||||
cards = sorted(
|
cards = sorted(
|
||||||
TarotCard.objects.filter(deck_variant=deck),
|
TarotCard.objects.filter(deck_variant=deck),
|
||||||
key=lambda c: (0 if c.arcana == "MAJOR" else 1, _suit_order.get(c.suit or "", 9), c.number),
|
key=lambda c: (0 if c.arcana == "MAJOR" else 1, _suit_order.get(c.suit or "", 9), c.number),
|
||||||
|
|||||||
23
src/apps/lyric/migrations/0017_ap_keypair_fields.py
Normal file
23
src/apps/lyric/migrations/0017_ap_keypair_fields.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-02 19:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('lyric', '0016_backfill_unlocked_decks'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='ap_private_key',
|
||||||
|
field=models.TextField(blank=True, default=''),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='ap_public_key',
|
||||||
|
field=models.TextField(blank=True, default=''),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -44,6 +44,8 @@ class User(AbstractBaseUser):
|
|||||||
unlocked_decks = models.ManyToManyField(
|
unlocked_decks = models.ManyToManyField(
|
||||||
"epic.DeckVariant", blank=True, related_name="unlocked_by",
|
"epic.DeckVariant", blank=True, related_name="unlocked_by",
|
||||||
)
|
)
|
||||||
|
ap_public_key = models.TextField(blank=True, default="")
|
||||||
|
ap_private_key = models.TextField(blank=True, default="")
|
||||||
|
|
||||||
is_staff = models.BooleanField(default=False)
|
is_staff = models.BooleanField(default=False)
|
||||||
is_superuser = models.BooleanField(default=False)
|
is_superuser = models.BooleanField(default=False)
|
||||||
@@ -52,6 +54,24 @@ class User(AbstractBaseUser):
|
|||||||
REQUIRED_FIELDS = []
|
REQUIRED_FIELDS = []
|
||||||
USERNAME_FIELD = "email"
|
USERNAME_FIELD = "email"
|
||||||
|
|
||||||
|
def ensure_keypair(self):
|
||||||
|
"""Generate and persist an RSA-2048 keypair if not already set."""
|
||||||
|
if self.ap_public_key:
|
||||||
|
return
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
self.ap_public_key = private_key.public_key().public_bytes(
|
||||||
|
serialization.Encoding.PEM,
|
||||||
|
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
).decode()
|
||||||
|
self.ap_private_key = private_key.private_bytes(
|
||||||
|
serialization.Encoding.PEM,
|
||||||
|
serialization.PrivateFormat.PKCS8,
|
||||||
|
serialization.NoEncryption(),
|
||||||
|
).decode()
|
||||||
|
self.save(update_fields=["ap_public_key", "ap_private_key"])
|
||||||
|
|
||||||
def has_perm(self, perm, obj=None):
|
def has_perm(self, perm, obj=None):
|
||||||
return self.is_superuser
|
return self.is_superuser
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from django import template
|
from django import template
|
||||||
|
from django.utils import dateformat, timezone
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
@@ -17,6 +18,29 @@ def truncate_email(email):
|
|||||||
|
|
||||||
return local + "@" + domain_name + "." + domain_tld
|
return local + "@" + domain_name + "." + domain_tld
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def relative_ts(dt):
|
||||||
|
"""Return a compact relative timestamp string for a datetime value.
|
||||||
|
|
||||||
|
< 24 h → "3:07 a.m."
|
||||||
|
< 7 d → "Thu"
|
||||||
|
< 1 y → "07 Mar"
|
||||||
|
≥ 1 y → "07 Mar 2025"
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return ""
|
||||||
|
local_dt = timezone.localtime(dt)
|
||||||
|
diff = timezone.now() - dt
|
||||||
|
if diff.total_seconds() < 86400:
|
||||||
|
return dateformat.format(local_dt, "g:i a")
|
||||||
|
elif diff.days < 7:
|
||||||
|
return dateformat.format(local_dt, "D")
|
||||||
|
elif diff.days < 365:
|
||||||
|
return dateformat.format(local_dt, "d M")
|
||||||
|
else:
|
||||||
|
return dateformat.format(local_dt, "d M Y")
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def display_name(user):
|
def display_name(user):
|
||||||
if user is None:
|
if user is None:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from . import views as lyric_views
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('send_login_email', lyric_views.send_login_email, name='send_login_email'),
|
path('send_login_email', lyric_views.send_login_email, name='send_login_email'),
|
||||||
path('login', lyric_views.login, name='login'),
|
path('login', lyric_views.login, name='login'),
|
||||||
path('logout', auth_views.LogoutView.as_view(next_page='/'), name='logout')
|
path('logout', auth_views.LogoutView.as_view(next_page='/'), name='logout'),
|
||||||
|
path('dev-login/<str:session_key>/', lyric_views.dev_login, name='dev_login'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
from django.conf import settings
|
||||||
from django.contrib import auth, messages
|
from django.contrib import auth, messages
|
||||||
|
from django.http import Http404
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
@@ -27,3 +29,13 @@ def login(request):
|
|||||||
else:
|
else:
|
||||||
messages.error(request, "Invalid login link!—please request another")
|
messages.error(request, "Invalid login link!—please request another")
|
||||||
return redirect("/")
|
return redirect("/")
|
||||||
|
|
||||||
|
|
||||||
|
def dev_login(request, session_key):
|
||||||
|
"""DEBUG-only: set session cookie and redirect. Used by setup_sig_session command."""
|
||||||
|
if not settings.DEBUG:
|
||||||
|
raise Http404
|
||||||
|
next_url = request.GET.get("next", "/")
|
||||||
|
response = redirect(next_url)
|
||||||
|
response.set_cookie(settings.SESSION_COOKIE_NAME, session_key, httponly=True)
|
||||||
|
return response
|
||||||
|
|||||||
@@ -1,4 +1,30 @@
|
|||||||
def user_palette(request):
|
def user_palette(request):
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
return {"user_palette": request.user.palette}
|
return {"user_palette": request.user.palette}
|
||||||
return {"user_palette": "palette-default"}
|
return {"user_palette": "palette-default"}
|
||||||
|
|
||||||
|
|
||||||
|
def navbar_context(request):
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return {}
|
||||||
|
from django.db.models import Max, Q
|
||||||
|
from django.urls import reverse
|
||||||
|
from apps.epic.models import Room
|
||||||
|
|
||||||
|
recent_room = (
|
||||||
|
Room.objects.filter(
|
||||||
|
Q(owner=request.user) | Q(gate_slots__gamer=request.user)
|
||||||
|
)
|
||||||
|
.annotate(last_event=Max("events__timestamp"))
|
||||||
|
.filter(last_event__isnull=False)
|
||||||
|
.order_by("-last_event")
|
||||||
|
.distinct()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if recent_room is None:
|
||||||
|
return {}
|
||||||
|
if recent_room.table_status:
|
||||||
|
url = reverse("epic:room", args=[recent_room.id])
|
||||||
|
else:
|
||||||
|
url = reverse("epic:gatekeeper", args=[recent_room.id])
|
||||||
|
return {"navbar_recent_room_url": url}
|
||||||
29
src/core/middleware.py
Normal file
29
src/core/middleware.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import zoneinfo
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class TimezoneMiddleware:
|
||||||
|
"""Activate the user's local timezone from the ``user_tz`` cookie.
|
||||||
|
|
||||||
|
The cookie is set client-side via ``Intl.DateTimeFormat().resolvedOptions().timeZone``
|
||||||
|
on every page load, so it reflects the browser's OS timezone rather than
|
||||||
|
the server's configured TIME_ZONE. Invalid or absent cookies fall back to
|
||||||
|
Django's default (UTC).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
tz_name = request.COOKIES.get("user_tz")
|
||||||
|
if tz_name:
|
||||||
|
try:
|
||||||
|
timezone.activate(zoneinfo.ZoneInfo(tz_name))
|
||||||
|
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
|
||||||
|
timezone.deactivate()
|
||||||
|
else:
|
||||||
|
timezone.deactivate()
|
||||||
|
response = self.get_response(request)
|
||||||
|
timezone.deactivate()
|
||||||
|
return response
|
||||||
@@ -57,12 +57,13 @@ INSTALLED_APPS = [
|
|||||||
# Board apps
|
# Board apps
|
||||||
'apps.dashboard',
|
'apps.dashboard',
|
||||||
'apps.gameboard',
|
'apps.gameboard',
|
||||||
|
'apps.billboard',
|
||||||
# Gamer apps
|
# Gamer apps
|
||||||
'apps.lyric',
|
'apps.lyric',
|
||||||
'apps.epic',
|
'apps.epic',
|
||||||
'apps.drama',
|
'apps.drama',
|
||||||
'apps.billboard',
|
|
||||||
# Custom apps
|
# Custom apps
|
||||||
|
'apps.ap',
|
||||||
'apps.api',
|
'apps.api',
|
||||||
'apps.applets',
|
'apps.applets',
|
||||||
'functional_tests',
|
'functional_tests',
|
||||||
@@ -79,6 +80,7 @@ MIDDLEWARE = [
|
|||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'core.middleware.TimezoneMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
@@ -100,6 +102,7 @@ TEMPLATES = [
|
|||||||
'django.contrib.auth.context_processors.auth',
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
'core.context_processors.user_palette',
|
'core.context_processors.user_palette',
|
||||||
|
'core.context_processors.navbar_context',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
0
src/core/tests/__init__.py
Normal file
0
src/core/tests/__init__.py
Normal file
0
src/core/tests/unit/__init__.py
Normal file
0
src/core/tests/unit/__init__.py
Normal file
109
src/core/tests/unit/test_context_processors.py
Normal file
109
src/core/tests/unit/test_context_processors.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from django.test import TestCase, RequestFactory
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.drama.models import GameEvent, record
|
||||||
|
from apps.epic.models import Room
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
from core.context_processors import navbar_context
|
||||||
|
|
||||||
|
|
||||||
|
class NavbarContextProcessorTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
|
||||||
|
def _anon_request(self):
|
||||||
|
req = self.factory.get("/")
|
||||||
|
req.user = MagicMock(is_authenticated=False)
|
||||||
|
return req
|
||||||
|
|
||||||
|
def _auth_request(self, user):
|
||||||
|
req = self.factory.get("/")
|
||||||
|
req.user = user
|
||||||
|
return req
|
||||||
|
|
||||||
|
def _room_with_event(self, owner, name="Test Room"):
|
||||||
|
room = Room.objects.create(name=name, owner=owner)
|
||||||
|
record(
|
||||||
|
room, GameEvent.SLOT_FILLED, actor=owner,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Coin-on-a-String", renewal_days=7,
|
||||||
|
)
|
||||||
|
return room
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Anonymous user #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_returns_empty_for_anonymous_user(self):
|
||||||
|
ctx = navbar_context(self._anon_request())
|
||||||
|
self.assertEqual(ctx, {})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Authenticated user — no rooms #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_returns_empty_when_no_rooms_with_events(self):
|
||||||
|
user = User.objects.create(email="disco@test.io")
|
||||||
|
# Room exists but has no events
|
||||||
|
Room.objects.create(name="Empty Room", owner=user)
|
||||||
|
|
||||||
|
ctx = navbar_context(self._auth_request(user))
|
||||||
|
self.assertEqual(ctx, {})
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Room in gate phase (no table_status) → gatekeeper URL #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_returns_gatekeeper_url_for_gate_phase_room(self):
|
||||||
|
user = User.objects.create(email="disco@test.io")
|
||||||
|
room = self._room_with_event(user)
|
||||||
|
|
||||||
|
ctx = navbar_context(self._auth_request(user))
|
||||||
|
self.assertIn("navbar_recent_room_url", ctx)
|
||||||
|
self.assertIn(str(room.id), ctx["navbar_recent_room_url"])
|
||||||
|
self.assertIn("gate", ctx["navbar_recent_room_url"])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Room in role-select (table_status set) → room view URL #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_returns_room_url_for_table_status_room(self):
|
||||||
|
user = User.objects.create(email="disco@test.io")
|
||||||
|
room = self._room_with_event(user)
|
||||||
|
room.table_status = Room.ROLE_SELECT
|
||||||
|
room.save()
|
||||||
|
|
||||||
|
ctx = navbar_context(self._auth_request(user))
|
||||||
|
self.assertIn("navbar_recent_room_url", ctx)
|
||||||
|
self.assertIn(str(room.id), ctx["navbar_recent_room_url"])
|
||||||
|
self.assertNotIn("gate", ctx["navbar_recent_room_url"])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Most recently updated room is chosen #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_returns_most_recently_updated_room(self):
|
||||||
|
user = User.objects.create(email="disco@test.io")
|
||||||
|
older_room = self._room_with_event(user, name="Older Room")
|
||||||
|
newer_room = self._room_with_event(user, name="Newer Room")
|
||||||
|
|
||||||
|
ctx = navbar_context(self._auth_request(user))
|
||||||
|
self.assertIn(str(newer_room.id), ctx["navbar_recent_room_url"])
|
||||||
|
self.assertNotIn(str(older_room.id), ctx["navbar_recent_room_url"])
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# User sees own rooms but not others' rooms they never joined #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_ignores_rooms_user_has_no_connection_to(self):
|
||||||
|
owner = User.objects.create(email="owner@test.io")
|
||||||
|
other = User.objects.create(email="other@test.io")
|
||||||
|
# Create a room belonging only to `owner`
|
||||||
|
self._room_with_event(owner)
|
||||||
|
|
||||||
|
ctx = navbar_context(self._auth_request(other))
|
||||||
|
self.assertEqual(ctx, {})
|
||||||
41
src/core/tests/unit/test_middleware.py
Normal file
41
src/core/tests/unit/test_middleware.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from django.http import HttpResponse
|
||||||
|
from django.test import RequestFactory, SimpleTestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from core.middleware import TimezoneMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
class TimezoneMiddlewareTest(SimpleTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.middleware = TimezoneMiddleware(lambda r: HttpResponse())
|
||||||
|
|
||||||
|
def test_activates_valid_timezone_from_cookie(self):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def get_response(request):
|
||||||
|
captured["tz"] = str(timezone.get_current_timezone())
|
||||||
|
return HttpResponse()
|
||||||
|
|
||||||
|
middleware = TimezoneMiddleware(get_response)
|
||||||
|
request = self.factory.get("/")
|
||||||
|
request.COOKIES["user_tz"] = "America/New_York"
|
||||||
|
middleware(request)
|
||||||
|
self.assertEqual(captured["tz"], "America/New_York")
|
||||||
|
|
||||||
|
def test_deactivates_after_response(self):
|
||||||
|
# Timezone activation must not leak into subsequent requests
|
||||||
|
request = self.factory.get("/")
|
||||||
|
request.COOKIES["user_tz"] = "America/New_York"
|
||||||
|
self.middleware(request)
|
||||||
|
self.assertEqual(str(timezone.get_current_timezone()), "UTC")
|
||||||
|
|
||||||
|
def test_invalid_timezone_cookie_does_not_raise(self):
|
||||||
|
request = self.factory.get("/")
|
||||||
|
request.COOKIES["user_tz"] = "Not/ATimezone"
|
||||||
|
self.middleware(request) # must not raise
|
||||||
|
|
||||||
|
def test_missing_cookie_does_not_raise(self):
|
||||||
|
request = self.factory.get("/")
|
||||||
|
self.middleware(request) # must not raise
|
||||||
@@ -2,6 +2,7 @@ from django.contrib import admin
|
|||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
|
from apps.ap import views as ap_views
|
||||||
from apps.dashboard import views as dash_views
|
from apps.dashboard import views as dash_views
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ urlpatterns = [
|
|||||||
path('gameboard/', include('apps.gameboard.urls')),
|
path('gameboard/', include('apps.gameboard.urls')),
|
||||||
path('gameboard/', include('apps.epic.urls')),
|
path('gameboard/', include('apps.epic.urls')),
|
||||||
path('billboard/', include('apps.billboard.urls')),
|
path('billboard/', include('apps.billboard.urls')),
|
||||||
|
path('ap/', include('apps.ap.urls')),
|
||||||
|
path('.well-known/webfinger', ap_views.webfinger, name='webfinger'),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Please remove the following urlpattern
|
# Please remove the following urlpattern
|
||||||
|
|||||||
@@ -37,20 +37,23 @@ def wait(fn):
|
|||||||
# Functional Tests
|
# Functional Tests
|
||||||
class FunctionalTest(StaticLiveServerTestCase):
|
class FunctionalTest(StaticLiveServerTestCase):
|
||||||
# Helper methods
|
# Helper methods
|
||||||
def setUp(self):
|
def _make_browser(self, width=1366, height=900):
|
||||||
|
"""Create a Firefox instance sized to width×height."""
|
||||||
options = webdriver.FirefoxOptions()
|
options = webdriver.FirefoxOptions()
|
||||||
headless = os.environ.get("HEADLESS")
|
if os.environ.get("HEADLESS"):
|
||||||
if headless:
|
|
||||||
options.add_argument("--headless")
|
options.add_argument("--headless")
|
||||||
self.browser = webdriver.Firefox(options=options)
|
browser = webdriver.Firefox(options=options)
|
||||||
if headless:
|
browser.set_window_size(width, height)
|
||||||
self.browser.set_window_size(1366, 900)
|
return browser
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.browser = self._make_browser(1366, 900)
|
||||||
self.test_server = os.environ.get("TEST_SERVER")
|
self.test_server = os.environ.get("TEST_SERVER")
|
||||||
if self.test_server:
|
if self.test_server:
|
||||||
self.live_server_url = 'http://' + self.test_server
|
self.live_server_url = 'http://' + self.test_server
|
||||||
reset_database(self.test_server)
|
reset_database(self.test_server)
|
||||||
Applet.objects.get_or_create(slug="new-note", defaults={"name": "New Note"})
|
Applet.objects.get_or_create(slug="new-note", defaults={"name": "New Note"})
|
||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
if self._test_has_failed():
|
if self._test_has_failed():
|
||||||
if not SCREEN_DUMP_LOCATION.exists():
|
if not SCREEN_DUMP_LOCATION.exists():
|
||||||
@@ -148,8 +151,7 @@ class ChannelsFunctionalTest(ChannelsLiveServerTestCase):
|
|||||||
if headless:
|
if headless:
|
||||||
options.add_argument("--headless")
|
options.add_argument("--headless")
|
||||||
self.browser = webdriver.Firefox(options=options)
|
self.browser = webdriver.Firefox(options=options)
|
||||||
if headless:
|
self.browser.set_window_size(1366, 900)
|
||||||
self.browser.set_window_size(1366, 900)
|
|
||||||
self.test_server = os.environ.get("TEST_SERVER")
|
self.test_server = os.environ.get("TEST_SERVER")
|
||||||
if self.test_server:
|
if self.test_server:
|
||||||
self.live_server_url = 'http://' + self.test_server
|
self.live_server_url = 'http://' + self.test_server
|
||||||
|
|||||||
128
src/functional_tests/management/commands/setup_sig_session.py
Normal file
128
src/functional_tests/management/commands/setup_sig_session.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""
|
||||||
|
Management command for manual multi-user sig-select testing.
|
||||||
|
|
||||||
|
Creates (or reuses) a room with all 6 gate slots filled, roles assigned,
|
||||||
|
and table_status=SIG_SELECT. Prints one pre-auth URL per gamer so you can
|
||||||
|
paste them into 6 Firefox Multi-Account Container tabs.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python src/manage.py setup_sig_session
|
||||||
|
python src/manage.py setup_sig_session --base-url http://localhost:8000
|
||||||
|
python src/manage.py setup_sig_session --room <uuid> # reuse existing room
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
|
||||||
|
from django.contrib.sessions.backends.db import SessionStore
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat, TarotCard
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
GAMERS = [
|
||||||
|
("founder@test.io", "discoman"),
|
||||||
|
("amigo@test.io", "amigo"),
|
||||||
|
("bud@test.io", "bud"),
|
||||||
|
("pal@test.io", "pal"),
|
||||||
|
("dude@test.io", "dude"),
|
||||||
|
("bro@test.io", "bro"),
|
||||||
|
]
|
||||||
|
|
||||||
|
ROLES = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_earthman():
|
||||||
|
"""Return (or create) the Earthman DeckVariant with enough sig-deck cards seeded."""
|
||||||
|
earthman, _ = DeckVariant.objects.get_or_create(
|
||||||
|
slug="earthman",
|
||||||
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||||
|
)
|
||||||
|
_NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
|
||||||
|
for suit in ("WANDS", "PENTACLES", "SWORDS", "CUPS"):
|
||||||
|
for number in (11, 12, 13, 14):
|
||||||
|
TarotCard.objects.get_or_create(
|
||||||
|
deck_variant=earthman,
|
||||||
|
slug=f"{_NAME[number].lower()}-of-{suit.lower()}-em",
|
||||||
|
defaults={
|
||||||
|
"arcana": "MINOR",
|
||||||
|
"suit": suit,
|
||||||
|
"number": number,
|
||||||
|
"name": f"{_NAME[number]} of {suit.capitalize()}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return earthman
|
||||||
|
|
||||||
|
|
||||||
|
def _make_session(user):
|
||||||
|
session = SessionStore()
|
||||||
|
session[SESSION_KEY] = str(user.pk)
|
||||||
|
session[BACKEND_SESSION_KEY] = "apps.lyric.authentication.PasswordlessAuthenticationBackend"
|
||||||
|
session[HASH_SESSION_KEY] = user.get_session_auth_hash()
|
||||||
|
session.save()
|
||||||
|
return session.session_key
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Set up a SIG_SELECT room and print pre-auth URLs for all six gamers"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--base-url", default="http://localhost:8000")
|
||||||
|
parser.add_argument("--room", default=None, help="UUID of an existing room to reuse")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
base_url = options["base_url"].rstrip("/")
|
||||||
|
earthman = _ensure_earthman()
|
||||||
|
|
||||||
|
# ── Users ────────────────────────────────────────────────────────────
|
||||||
|
users = []
|
||||||
|
for email, _ in GAMERS:
|
||||||
|
user, _ = User.objects.get_or_create(email=email)
|
||||||
|
user.is_staff = True
|
||||||
|
user.is_superuser = True
|
||||||
|
if not user.equipped_deck:
|
||||||
|
user.equipped_deck = earthman
|
||||||
|
user.save()
|
||||||
|
users.append(user)
|
||||||
|
|
||||||
|
# ── Room ─────────────────────────────────────────────────────────────
|
||||||
|
if options["room"]:
|
||||||
|
room = Room.objects.get(pk=options["room"])
|
||||||
|
else:
|
||||||
|
room = Room.objects.create(
|
||||||
|
name="Sig Select Test Room",
|
||||||
|
owner=users[0],
|
||||||
|
visibility=Room.PUBLIC,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Gate slots ───────────────────────────────────────────────────────
|
||||||
|
for i, user in enumerate(users, start=1):
|
||||||
|
slot = room.gate_slots.get(slot_number=i)
|
||||||
|
slot.gamer = user
|
||||||
|
slot.status = GateSlot.FILLED
|
||||||
|
slot.save()
|
||||||
|
|
||||||
|
room.gate_status = Room.OPEN
|
||||||
|
room.save()
|
||||||
|
|
||||||
|
# ── Table seats + roles ──────────────────────────────────────────────
|
||||||
|
for i, (user, role) in enumerate(zip(users, ROLES), start=1):
|
||||||
|
TableSeat.objects.update_or_create(
|
||||||
|
room=room, slot_number=i,
|
||||||
|
defaults={"gamer": user, "role": role, "role_revealed": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
room.table_status = Room.SIG_SELECT
|
||||||
|
room.save()
|
||||||
|
|
||||||
|
# ── Print URLs ───────────────────────────────────────────────────────
|
||||||
|
room_path = f"/gameboard/room/{room.pk}/"
|
||||||
|
self.stdout.write(f"\nRoom: {base_url}{room_path}\n")
|
||||||
|
self.stdout.write(f"{'Container':<12} {'Email':<22} {'Role':<6} URL")
|
||||||
|
self.stdout.write("─" * 100)
|
||||||
|
|
||||||
|
for (email, container), user, role in zip(GAMERS, users, ROLES):
|
||||||
|
session_key = _make_session(user)
|
||||||
|
url = f"{base_url}/lyric/dev-login/{session_key}/?next={room_path}"
|
||||||
|
self.stdout.write(f"{container:<12} {email:<22} {role:<6} {url}")
|
||||||
|
|
||||||
|
self.stdout.write("")
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import datetime
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
from .base import FunctionalTest
|
from .base import FunctionalTest
|
||||||
@@ -94,11 +97,11 @@ class BillboardScrollTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Gate fill events are rendered as prose
|
# Gate fill events are rendered as prose
|
||||||
self.assertIn("deposits a Coin-on-a-String for slot 1 (7 days)", scroll.text)
|
self.assertIn("deposits a Coin-on-a-String for slot 1 (expires in 7 days).", scroll.text)
|
||||||
self.assertIn("deposits a Free Token for slot 2 (7 days)", scroll.text)
|
self.assertIn("deposits a Free Token for slot 2 (expires in 7 days).", scroll.text)
|
||||||
|
|
||||||
# Role selection event is rendered as prose
|
# Role selection event is rendered as prose
|
||||||
self.assertIn("elects to start as Player", scroll.text)
|
self.assertIn("elects to start as the Player", scroll.text)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Test 3 — current user's events are right-aligned; others' are left #
|
# Test 3 — current user's events are right-aligned; others' are left #
|
||||||
@@ -284,3 +287,70 @@ class BillscrollAppletsTest(FunctionalTest):
|
|||||||
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
|
lambda: self.browser.find_element(By.ID, "id_drama_scroll")
|
||||||
)
|
)
|
||||||
self.assertIn("Coin-on-a-String", scroll.text)
|
self.assertIn("Coin-on-a-String", scroll.text)
|
||||||
|
|
||||||
|
|
||||||
|
class BillscrollEntryLayoutTest(FunctionalTest):
|
||||||
|
"""
|
||||||
|
FT: each drama entry renders as a 90/10 row — event body at 90%,
|
||||||
|
relative timestamp at 10%; timestamp text format varies with age.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.founder = User.objects.create(email="founder@layout.io")
|
||||||
|
self.room = Room.objects.create(name="Layout Chamber", owner=self.founder)
|
||||||
|
# A fresh (< 24 h) event — timestamp is auto_now_add so always recent
|
||||||
|
record(
|
||||||
|
self.room, GameEvent.SLOT_FILLED, actor=self.founder,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Fresh Coin", renewal_days=7,
|
||||||
|
)
|
||||||
|
# An old (> 1 year) event — backdate via queryset update to bypass auto_now_add
|
||||||
|
old = record(
|
||||||
|
self.room, GameEvent.SLOT_FILLED, actor=self.founder,
|
||||||
|
slot_number=2, token_type="coin",
|
||||||
|
token_display="Ancient Coin", renewal_days=7,
|
||||||
|
)
|
||||||
|
GameEvent.objects.filter(pk=old.pk).update(
|
||||||
|
timestamp=timezone.now() - datetime.timedelta(days=400)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _go_to_scroll(self):
|
||||||
|
self.create_pre_authenticated_session("founder@layout.io")
|
||||||
|
self.browser.get(
|
||||||
|
self.live_server_url + f"/billboard/room/{self.room.id}/scroll/"
|
||||||
|
)
|
||||||
|
return self.wait_for(
|
||||||
|
lambda: self.browser.find_elements(By.CSS_SELECTOR, ".drama-event")
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test 1 — each entry has a body column and a time column #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_each_drama_entry_has_body_and_time_columns(self):
|
||||||
|
events = self._go_to_scroll()
|
||||||
|
self.assertEqual(len(events), 2)
|
||||||
|
for event_el in events:
|
||||||
|
event_el.find_element(By.CSS_SELECTOR, ".drama-event-body")
|
||||||
|
event_el.find_element(By.CSS_SELECTOR, ".drama-event-time")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test 2 — recent entry timestamp shows HH:MM a.m./p.m. #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_recent_event_shows_time_format(self):
|
||||||
|
events = self._go_to_scroll()
|
||||||
|
# events[0] is the backdated record (oldest); events[1] is fresh
|
||||||
|
recent_ts = events[1].find_element(By.CSS_SELECTOR, ".drama-event-time")
|
||||||
|
self.assertRegex(recent_ts.text, r"\d+:\d+\s+[ap]\.m\.")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test 3 — entry > 1 year old shows DD Mon YYYY #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_old_event_shows_date_with_year(self):
|
||||||
|
events = self._go_to_scroll()
|
||||||
|
# events[0] is the backdated record (oldest first, ascending order)
|
||||||
|
old_ts = events[0].find_element(By.CSS_SELECTOR, ".drama-event-time")
|
||||||
|
self.assertRegex(old_ts.text, r"\d{2}\s+[A-Z][a-z]{2}\s+\d{4}")
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
from selenium.webdriver.common.action_chains import ActionChains
|
from selenium.webdriver.common.action_chains import ActionChains
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
@@ -363,6 +365,16 @@ class GameKitPageTest(FunctionalTest):
|
|||||||
slug=slug,
|
slug=slug,
|
||||||
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "gameboard"},
|
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "gameboard"},
|
||||||
)
|
)
|
||||||
|
for slug, name in [
|
||||||
|
("gk-trinkets", "Trinkets"),
|
||||||
|
("gk-tokens", "Tokens"),
|
||||||
|
("gk-decks", "Card Decks"),
|
||||||
|
("gk-dice", "Dice Sets"),
|
||||||
|
]:
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug=slug,
|
||||||
|
defaults={"name": name, "grid_cols": 3, "grid_rows": 3, "context": "game-kit"},
|
||||||
|
)
|
||||||
self.earthman, _ = DeckVariant.objects.get_or_create(
|
self.earthman, _ = DeckVariant.objects.get_or_create(
|
||||||
slug="earthman",
|
slug="earthman",
|
||||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||||
@@ -430,27 +442,7 @@ class GameKitPageTest(FunctionalTest):
|
|||||||
self.assertGreater(len(visible), 1)
|
self.assertGreater(len(visible), 1)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Test 11 — next button advances the active card #
|
# Test 11 — clicking outside the modal closes it #
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def test_fan_next_button_advances_card(self):
|
|
||||||
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
|
|
||||||
self.wait_for(
|
|
||||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card")
|
|
||||||
).click()
|
|
||||||
first_index = self.wait_for(
|
|
||||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active")
|
|
||||||
).get_attribute("data-index")
|
|
||||||
self.browser.find_element(By.ID, "id_fan_next").click()
|
|
||||||
self.wait_for(
|
|
||||||
lambda: self.assertNotEqual(
|
|
||||||
self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active").get_attribute("data-index"),
|
|
||||||
first_index,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Test 12 — clicking outside the modal closes it #
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_pressing_escape_closes_fan_modal(self):
|
def test_pressing_escape_closes_fan_modal(self):
|
||||||
@@ -464,36 +456,3 @@ class GameKitPageTest(FunctionalTest):
|
|||||||
dialog.send_keys(Keys.ESCAPE)
|
dialog.send_keys(Keys.ESCAPE)
|
||||||
self.wait_for(lambda: self.assertFalse(dialog.is_displayed()))
|
self.wait_for(lambda: self.assertFalse(dialog.is_displayed()))
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Test 13 — reopening the modal remembers scroll position #
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def test_fan_remembers_position_on_reopen(self):
|
|
||||||
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
|
|
||||||
deck_card = self.wait_for(
|
|
||||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card")
|
|
||||||
)
|
|
||||||
deck_card.click()
|
|
||||||
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active"))
|
|
||||||
# Advance 3 cards
|
|
||||||
for _ in range(3):
|
|
||||||
self.browser.find_element(By.ID, "id_fan_next").click()
|
|
||||||
saved_index = self.wait_for(
|
|
||||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active").get_attribute("data-index")
|
|
||||||
)
|
|
||||||
# Close via ESC
|
|
||||||
from selenium.webdriver.common.keys import Keys
|
|
||||||
self.browser.find_element(By.ID, "id_tarot_fan_dialog").send_keys(Keys.ESCAPE)
|
|
||||||
self.wait_for(
|
|
||||||
lambda: self.assertFalse(
|
|
||||||
self.browser.find_element(By.ID, "id_tarot_fan_dialog").is_displayed()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Reopen and verify position restored
|
|
||||||
deck_card.click()
|
|
||||||
self.wait_for(
|
|
||||||
lambda: self.assertEqual(
|
|
||||||
self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active").get_attribute("data-index"),
|
|
||||||
saved_index,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ class DashboardMaintenanceTest(FunctionalTest):
|
|||||||
class AppletMenuDismissTest(FunctionalTest):
|
class AppletMenuDismissTest(FunctionalTest):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
# Portrait viewport: sidebars don't activate, h2 sits safely above
|
||||||
|
# #id_dash_content and can't be obscured by it regardless of font metrics.
|
||||||
|
self.browser.set_window_size(800, 1200)
|
||||||
Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
|
Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
|
||||||
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
|
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
|
||||||
self.create_pre_authenticated_session("discoman@example.com")
|
self.create_pre_authenticated_session("discoman@example.com")
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from selenium.webdriver.common.by import By
|
|||||||
from selenium.webdriver.common.keys import Keys
|
from selenium.webdriver.common.keys import Keys
|
||||||
|
|
||||||
from .base import FunctionalTest
|
from .base import FunctionalTest
|
||||||
from apps.epic.models import Room
|
from apps.epic.models import DeckVariant, Room
|
||||||
from apps.lyric.models import Token, User
|
from apps.lyric.models import Token, User
|
||||||
|
|
||||||
|
|
||||||
@@ -12,8 +12,15 @@ class GameKitTest(FunctionalTest):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
self.earthman, _ = DeckVariant.objects.get_or_create(
|
||||||
|
slug="earthman",
|
||||||
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||||
|
)
|
||||||
self.create_pre_authenticated_session("gamer@kit.io")
|
self.create_pre_authenticated_session("gamer@kit.io")
|
||||||
self.gamer = User.objects.get(email="gamer@kit.io")
|
self.gamer = User.objects.get(email="gamer@kit.io")
|
||||||
|
self.gamer.equipped_deck = self.earthman
|
||||||
|
self.gamer.save(update_fields=["equipped_deck"])
|
||||||
|
self.gamer.unlocked_decks.add(self.earthman)
|
||||||
self.token = self.gamer.tokens.filter(token_type=Token.COIN).first()
|
self.token = self.gamer.tokens.filter(token_type=Token.COIN).first()
|
||||||
self.room = Room.objects.create(name="Kit Room", owner=self.gamer)
|
self.room = Room.objects.create(name="Kit Room", owner=self.gamer)
|
||||||
self.gate_url = self.live_server_url + f"/gameboard/room/{self.room.id}/gate/"
|
self.gate_url = self.live_server_url + f"/gameboard/room/{self.room.id}/gate/"
|
||||||
@@ -93,7 +100,10 @@ class GameKitTest(FunctionalTest):
|
|||||||
By.CSS_SELECTOR, "#id_kit_bag_dialog .kit-bag-deck"
|
By.CSS_SELECTOR, "#id_kit_bag_dialog .kit-bag-deck"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
ActionChains(self.browser).move_to_element(deck_el).perform()
|
# Dispatch mouseenter via JS — more reliable than ActionChains in headless CI
|
||||||
|
self.browser.execute_script(
|
||||||
|
"arguments[0].dispatchEvent(new Event('mouseenter'))", deck_el
|
||||||
|
)
|
||||||
tooltip = self.browser.find_element(
|
tooltip = self.browser.find_element(
|
||||||
By.CSS_SELECTOR, "#id_kit_bag_dialog .kit-bag-deck .token-tooltip"
|
By.CSS_SELECTOR, "#id_kit_bag_dialog .kit-bag-deck .token-tooltip"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from .base import FunctionalTest
|
|||||||
from apps.applets.models import Applet
|
from apps.applets.models import Applet
|
||||||
from apps.epic.models import Room, GateSlot, select_token
|
from apps.epic.models import Room, GateSlot, select_token
|
||||||
from apps.lyric.models import Token, User
|
from apps.lyric.models import Token, User
|
||||||
|
from .test_room_role_select import _fill_room_via_orm
|
||||||
|
|
||||||
|
|
||||||
class GatekeeperTest(FunctionalTest):
|
class GatekeeperTest(FunctionalTest):
|
||||||
@@ -247,7 +248,7 @@ class GatekeeperTest(FunctionalTest):
|
|||||||
|
|
||||||
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
|
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-abandon")
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_room_menu .btn-abandon")
|
||||||
).click()
|
).click()
|
||||||
self.confirm_guard()
|
self.confirm_guard()
|
||||||
|
|
||||||
@@ -585,3 +586,117 @@ class GameKitInsertTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
self.assertTrue(Token.objects.filter(id=pass_token.id).exists())
|
self.assertTrue(Token.objects.filter(id=pass_token.id).exists())
|
||||||
self.assertEqual(self.browser.current_url, self.gate_url)
|
self.assertEqual(self.browser.current_url, self.gate_url)
|
||||||
|
|
||||||
|
|
||||||
|
class PositionIndicatorsTest(FunctionalTest):
|
||||||
|
"""Hex-side position indicators — always rendered outside the gatekeeper modal."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||||
|
self.room = Room.objects.create(name="Position Test Room", owner=self.founder)
|
||||||
|
self.gate_url = (
|
||||||
|
f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test P1 — 6 position circles present in strip alongside gatekeeper #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_position_indicators_visible_alongside_gatekeeper(self):
|
||||||
|
self.browser.get(self.gate_url)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-overlay")
|
||||||
|
)
|
||||||
|
# Six .gate-slot elements are rendered in .position-strip, outside modal
|
||||||
|
strip = self.browser.find_element(By.CSS_SELECTOR, ".position-strip")
|
||||||
|
slots = strip.find_elements(By.CSS_SELECTOR, ".gate-slot")
|
||||||
|
self.assertEqual(len(slots), 6)
|
||||||
|
for slot in slots:
|
||||||
|
self.assertTrue(slot.is_displayed())
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test P2 — URL drops /gate/ after pick_roles #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_url_drops_gate_after_pick_roles(self):
|
||||||
|
_fill_room_via_orm(self.room, [
|
||||||
|
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||||
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
|
])
|
||||||
|
self.room.table_status = Room.ROLE_SELECT
|
||||||
|
self.room.save()
|
||||||
|
|
||||||
|
self.browser.get(self.gate_url)
|
||||||
|
expected_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/"
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertEqual(self.browser.current_url, expected_url)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test P3 — Gate-slot circles live outside the modal #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_position_circles_outside_gatekeeper_modal(self):
|
||||||
|
"""The numbered position circles must NOT be descendants of .gate-modal —
|
||||||
|
they live in .position-strip which sits above the backdrop."""
|
||||||
|
self.browser.get(self.gate_url)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal")
|
||||||
|
)
|
||||||
|
# No .gate-slot inside the modal
|
||||||
|
modal_slots = self.browser.find_elements(
|
||||||
|
By.CSS_SELECTOR, ".gate-modal .gate-slot"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(modal_slots), 0)
|
||||||
|
# All 6 live in .position-strip
|
||||||
|
strip_slots = self.browser.find_elements(
|
||||||
|
By.CSS_SELECTOR, ".position-strip .gate-slot"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(strip_slots), 6)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test P4 — Each circle displays its slot number #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_position_circle_shows_slot_number(self):
|
||||||
|
self.browser.get(self.gate_url)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".position-strip")
|
||||||
|
)
|
||||||
|
for n in range(1, 7):
|
||||||
|
slot_el = self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, f".position-strip .gate-slot[data-slot='{n}']"
|
||||||
|
)
|
||||||
|
self.assertIn(str(n), slot_el.text)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test P5 — Filled slot carries .filled class in strip #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_filled_slot_shown_in_strip(self):
|
||||||
|
from apps.epic.models import GateSlot
|
||||||
|
slot = self.room.gate_slots.get(slot_number=1)
|
||||||
|
slot.gamer = self.founder
|
||||||
|
slot.status = GateSlot.FILLED
|
||||||
|
slot.save()
|
||||||
|
|
||||||
|
self.browser.get(self.gate_url)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".position-strip")
|
||||||
|
)
|
||||||
|
slot1 = self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".position-strip .gate-slot[data-slot='1']"
|
||||||
|
)
|
||||||
|
self.assertIn("filled", slot1.get_attribute("class"))
|
||||||
|
slot2 = self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".position-strip .gate-slot[data-slot='2']"
|
||||||
|
)
|
||||||
|
self.assertIn("empty", slot2.get_attribute("class"))
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ class JasmineTest(FunctionalTest):
|
|||||||
|
|
||||||
def check_results():
|
def check_results():
|
||||||
result = self.browser.find_element(By.CSS_SELECTOR, ".jasmine-overall-result")
|
result = self.browser.find_element(By.CSS_SELECTOR, ".jasmine-overall-result")
|
||||||
self.assertIn("0 failures", result.text)
|
if "0 failures" not in result.text:
|
||||||
|
failures = self.browser.find_elements(
|
||||||
|
By.CSS_SELECTOR, ".jasmine-failed .jasmine-description"
|
||||||
|
)
|
||||||
|
detail = "\n".join(f.text for f in failures) if failures else "(no detail)"
|
||||||
|
self.fail(f"{result.text}\nFailing specs:\n{detail}")
|
||||||
|
|
||||||
self.wait_for(check_results)
|
self.wait_for(check_results)
|
||||||
|
|||||||
182
src/functional_tests/test_navbar.py
Normal file
182
src/functional_tests/test_navbar.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
from django.urls import reverse
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
|
from apps.drama.models import GameEvent, record
|
||||||
|
from apps.epic.models import Room
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
from .base import FunctionalTest
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_rect(browser):
|
||||||
|
"""Return the guard portal's bounding rect (reflects CSS transform)."""
|
||||||
|
return browser.execute_script(
|
||||||
|
"return document.getElementById('id_guard_portal').getBoundingClientRect().toJSON()"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _elem_rect(browser, element):
|
||||||
|
"""Return an element's bounding rect."""
|
||||||
|
return browser.execute_script(
|
||||||
|
"return arguments[0].getBoundingClientRect().toJSON()", element
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NavbarByeTest(FunctionalTest):
|
||||||
|
"""
|
||||||
|
The BYE btn-abandon replaces LOG OUT in the identity group.
|
||||||
|
It should confirm before logging out and its tooltip must appear below
|
||||||
|
the button (not above, which would be off-screen in the navbar).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.create_pre_authenticated_session("disco@test.io")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# T1 — BYE btn present; "Log Out" text gone #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_bye_btn_replaces_log_out(self):
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_logout"))
|
||||||
|
|
||||||
|
logout_btn = self.browser.find_element(By.ID, "id_logout")
|
||||||
|
self.assertEqual(logout_btn.text, "BYE")
|
||||||
|
self.assertIn("btn-abandon", logout_btn.get_attribute("class"))
|
||||||
|
self.assertNotIn("btn-primary", logout_btn.get_attribute("class"))
|
||||||
|
|
||||||
|
# Old "Log Out" text nowhere in navbar
|
||||||
|
navbar = self.browser.find_element(By.CSS_SELECTOR, ".navbar")
|
||||||
|
self.assertNotIn("Log Out", navbar.text)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# T2 — BYE tooltip appears below btn #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_bye_tooltip_appears_below_btn(self):
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_logout")
|
||||||
|
)
|
||||||
|
btn_rect = _elem_rect(self.browser, btn)
|
||||||
|
|
||||||
|
# Click BYE — guard should become active
|
||||||
|
self.browser.execute_script("arguments[0].click()", btn)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_guard_portal.active"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
portal_rect = _guard_rect(self.browser)
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
portal_rect["top"],
|
||||||
|
btn_rect["bottom"] - 2, # 2 px tolerance for sub-pixel rounding
|
||||||
|
"Guard portal should appear below the BYE btn, not above it",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# T3 — BYE btn logs out on confirm #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_bye_btn_logs_out_on_confirm(self):
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_logout")
|
||||||
|
)
|
||||||
|
self.browser.execute_script("arguments[0].click()", btn)
|
||||||
|
self.confirm_guard()
|
||||||
|
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, "input[name=email]")
|
||||||
|
)
|
||||||
|
navbar = self.browser.find_element(By.CSS_SELECTOR, ".navbar")
|
||||||
|
self.assertNotIn("disco@test.io", navbar.text)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# T4 — No CONT GAME btn when user has no rooms with events #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_cont_game_btn_absent_without_recent_room(self):
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_logout")
|
||||||
|
)
|
||||||
|
cont_game_btns = self.browser.find_elements(By.ID, "id_cont_game")
|
||||||
|
self.assertEqual(
|
||||||
|
len(cont_game_btns), 0,
|
||||||
|
"CONT GAME btn should not appear when user has no rooms with events",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NavbarContGameTest(FunctionalTest):
|
||||||
|
"""
|
||||||
|
When the authenticated user has at least one room with a game event the
|
||||||
|
CONT GAME btn-primary appears in the navbar and navigates to that
|
||||||
|
room on confirmation. Its tooltip must also appear below the button.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.create_pre_authenticated_session("disco@test.io")
|
||||||
|
self.user = User.objects.get(email="disco@test.io")
|
||||||
|
self.room = Room.objects.create(name="Arena of Peril", owner=self.user)
|
||||||
|
record(
|
||||||
|
self.room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Coin-on-a-String", renewal_days=7,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# T5 — CONT GAME btn present when recent room exists #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_cont_game_btn_present(self):
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_cont_game")
|
||||||
|
)
|
||||||
|
btn = self.browser.find_element(By.ID, "id_cont_game")
|
||||||
|
self.assertIn("btn-primary", btn.get_attribute("class"))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# T6 — CONT GAME tooltip appears below btn #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_cont_game_tooltip_appears_below_btn(self):
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_cont_game")
|
||||||
|
)
|
||||||
|
btn_rect = _elem_rect(self.browser, btn)
|
||||||
|
|
||||||
|
self.browser.execute_script("arguments[0].click()", btn)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_guard_portal.active"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
portal_rect = _guard_rect(self.browser)
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
portal_rect["top"],
|
||||||
|
btn_rect["bottom"] - 2,
|
||||||
|
"Guard portal should appear below the CONT GAME btn, not above it",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# T7 — CONT GAME navigates to the room on confirm #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_cont_game_navigates_to_room_on_confirm(self):
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_cont_game")
|
||||||
|
)
|
||||||
|
self.browser.execute_script("arguments[0].click()", btn)
|
||||||
|
self.confirm_guard()
|
||||||
|
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertIn(str(self.room.id), self.browser.current_url)
|
||||||
|
)
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import unittest
|
||||||
|
|
||||||
from django.conf import settings as django_settings
|
from django.conf import settings as django_settings
|
||||||
from django.test import tag
|
from django.test import tag
|
||||||
@@ -215,14 +216,7 @@ class RoleSelectTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# 7. Role card appears in inventory
|
# 7. Card stack returns to table centre
|
||||||
self.wait_for(
|
|
||||||
lambda: self.browser.find_element(
|
|
||||||
By.CSS_SELECTOR, "#id_inv_role_card .card"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 8. Card stack returns to table centre
|
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack")
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".card-stack")
|
||||||
)
|
)
|
||||||
@@ -322,46 +316,6 @@ class RoleSelectTest(FunctionalTest):
|
|||||||
cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_role_select .card")
|
cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_role_select .card")
|
||||||
self.assertEqual(len(cards), 5)
|
self.assertEqual(len(cards), 5)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
# Test 3d — Previously selected roles appear in inventory on re-entry#
|
|
||||||
# ------------------------------------------------------------------ #
|
|
||||||
|
|
||||||
def test_previously_selected_roles_shown_in_inventory_on_re_entry(self):
|
|
||||||
"""A multi-slot gamer who already chose some roles should see those
|
|
||||||
role cards pre-populated in the inventory when they re-enter the room."""
|
|
||||||
from apps.epic.models import TableSeat
|
|
||||||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
|
||||||
room = Room.objects.create(name="Inventory Re-entry Test", owner=founder)
|
|
||||||
_fill_room_via_orm(room, [
|
|
||||||
"founder@test.io", "founder@test.io",
|
|
||||||
"bud@test.io", "pal@test.io", "dude@test.io", "bro@test.io",
|
|
||||||
])
|
|
||||||
room.table_status = Room.ROLE_SELECT
|
|
||||||
room.save()
|
|
||||||
|
|
||||||
for slot in room.gate_slots.order_by("slot_number"):
|
|
||||||
TableSeat.objects.create(
|
|
||||||
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
|
||||||
)
|
|
||||||
# Founder's first slot has already chosen BC
|
|
||||||
TableSeat.objects.filter(room=room, slot_number=1).update(role="BC")
|
|
||||||
|
|
||||||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
|
||||||
self.create_pre_authenticated_session("founder@test.io")
|
|
||||||
self.browser.get(room_url)
|
|
||||||
|
|
||||||
# Inventory should contain exactly one pre-rendered card for BC
|
|
||||||
inv_cards = self.wait_for(
|
|
||||||
lambda: self.browser.find_elements(
|
|
||||||
By.CSS_SELECTOR, "#id_inv_role_card .card"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertEqual(len(inv_cards), 1)
|
|
||||||
self.assertIn(
|
|
||||||
"BUILDER",
|
|
||||||
inv_cards[0].text.upper(),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Test 4 — Click-away dismisses fan without selecting #
|
# Test 4 — Click-away dismisses fan without selecting #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -391,17 +345,13 @@ class RoleSelectTest(FunctionalTest):
|
|||||||
# Click the backdrop (outside the fan)
|
# Click the backdrop (outside the fan)
|
||||||
self.browser.find_element(By.CSS_SELECTOR, ".role-select-backdrop").click()
|
self.browser.find_element(By.CSS_SELECTOR, ".role-select-backdrop").click()
|
||||||
|
|
||||||
# Modal closes; stack still present; inventory still empty
|
# Modal closes; stack still present
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.assertEqual(
|
lambda: self.assertEqual(
|
||||||
len(self.browser.find_elements(By.ID, "id_role_select")), 0
|
len(self.browser.find_elements(By.ID, "id_role_select")), 0
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.browser.find_element(By.CSS_SELECTOR, ".card-stack")
|
self.browser.find_element(By.CSS_SELECTOR, ".card-stack")
|
||||||
self.assertEqual(
|
|
||||||
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_inv_role_card .card")),
|
|
||||||
0
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -495,12 +445,14 @@ class RoleSelectTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Test 7 — All roles revealed simultaneously after all gamers select #
|
# Test 8a — Hex seats carry role labels during role select #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_roles_revealed_simultaneously_after_all_select(self):
|
def test_seats_around_hex_have_role_labels(self):
|
||||||
|
"""During role select the 6 .table-seat elements carry data-role
|
||||||
|
attributes matching the fixed slot→role mapping (PC at slot 1, etc.)."""
|
||||||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||||
room = Room.objects.create(name="Reveal Test", owner=founder)
|
room = Room.objects.create(name="Seat Label Test", owner=founder)
|
||||||
_fill_room_via_orm(room, [
|
_fill_room_via_orm(room, [
|
||||||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||||
"pal@test.io", "dude@test.io", "bro@test.io",
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
@@ -512,33 +464,234 @@ class RoleSelectTest(FunctionalTest):
|
|||||||
self.create_pre_authenticated_session("founder@test.io")
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
self.browser.get(room_url)
|
self.browser.get(room_url)
|
||||||
|
|
||||||
# Assign all roles via ORM (simulating all gamers having chosen)
|
expected = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
||||||
from apps.epic.models import TableSeat
|
self.wait_for(
|
||||||
roles = ["PC", "BC", "SC", "AC", "NC", "EC"]
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".table-seat")
|
||||||
for i, slot in enumerate(room.gate_slots.order_by("slot_number")):
|
)
|
||||||
TableSeat.objects.create(
|
for slot_number, role_label in expected.items():
|
||||||
room=room,
|
seat = self.browser.find_element(
|
||||||
gamer=slot.gamer,
|
By.CSS_SELECTOR, f".table-seat[data-slot='{slot_number}']"
|
||||||
slot_number=slot.slot_number,
|
|
||||||
role=roles[i],
|
|
||||||
role_revealed=True,
|
|
||||||
)
|
)
|
||||||
room.table_status = Room.SIG_SELECT
|
self.assertEqual(seat.get_attribute("data-role"), role_label)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test 8b — Hex seats show .fa-ban when empty #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_seats_show_ban_icon_when_empty(self):
|
||||||
|
"""All 6 seats carry .fa-ban before any role has been chosen."""
|
||||||
|
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||||
|
room = Room.objects.create(name="Seat Ban Test", owner=founder)
|
||||||
|
_fill_room_via_orm(room, [
|
||||||
|
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||||
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
|
])
|
||||||
|
room.table_status = Room.ROLE_SELECT
|
||||||
room.save()
|
room.save()
|
||||||
|
for slot in room.gate_slots.order_by("slot_number"):
|
||||||
|
TableSeat.objects.create(
|
||||||
|
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
||||||
|
)
|
||||||
|
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||||||
|
|
||||||
self.browser.refresh()
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(room_url)
|
||||||
|
|
||||||
# All role cards in inventory are face-up
|
self.wait_for(
|
||||||
face_up_cards = self.wait_for(
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".table-seat")
|
||||||
lambda: self.browser.find_elements(
|
)
|
||||||
By.CSS_SELECTOR, "#id_inv_role_card .card.face-up"
|
seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat")
|
||||||
|
self.assertEqual(len(seats), 6)
|
||||||
|
for seat in seats:
|
||||||
|
self.assertTrue(
|
||||||
|
seat.find_elements(By.CSS_SELECTOR, ".fa-ban"),
|
||||||
|
f"Expected .fa-ban on seat slot {seat.get_attribute('data-slot')}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test 8c — Hex seat gets .fa-circle-check after role selected #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_seat_gets_check_after_role_selected(self):
|
||||||
|
"""After confirming a role pick the corresponding hex seat should
|
||||||
|
show .fa-circle-check and lose .fa-ban."""
|
||||||
|
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||||
|
room = Room.objects.create(name="Seat Check Test", owner=founder)
|
||||||
|
_fill_room_via_orm(room, [
|
||||||
|
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||||
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
|
])
|
||||||
|
room.table_status = Room.ROLE_SELECT
|
||||||
|
room.save()
|
||||||
|
for slot in room.gate_slots.order_by("slot_number"):
|
||||||
|
TableSeat.objects.create(
|
||||||
|
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
||||||
|
)
|
||||||
|
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||||||
|
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(room_url)
|
||||||
|
|
||||||
|
# Open fan, pick first card (SC — Shepherd), confirm guard
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||||||
|
)
|
||||||
|
).click()
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
||||||
|
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||||||
|
self.confirm_guard()
|
||||||
|
|
||||||
|
# Wait for tray animation to complete
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertFalse(
|
||||||
|
self.browser.execute_script("return Tray.isOpen()"),
|
||||||
|
"Tray should close after arc-in sequence",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertGreater(len(face_up_cards), 0)
|
|
||||||
|
|
||||||
# Partner indicator is visible
|
# The SC seat (slot 1) now shows check, no ban
|
||||||
self.wait_for(
|
self.wait_for(
|
||||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".partner-indicator")
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".table-seat[data-role='SC'] .fa-circle-check"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
len(self.browser.find_elements(
|
||||||
|
By.CSS_SELECTOR, ".table-seat[data-role='SC'] .fa-ban"
|
||||||
|
)),
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleSelectTrayTest(FunctionalTest):
|
||||||
|
"""After confirming a role pick, the role card enters the tray grid and
|
||||||
|
the tray opens to reveal it.
|
||||||
|
|
||||||
|
Portrait — card lands at the topmost grid square (first child, row 1 col 1).
|
||||||
|
Landscape — card lands at the leftmost grid square (first child, row 1 col 1).
|
||||||
|
"""
|
||||||
|
|
||||||
|
EMAILS = [
|
||||||
|
"slot1@test.io", "slot2@test.io", "slot3@test.io",
|
||||||
|
"slot4@test.io", "slot5@test.io", "slot6@test.io",
|
||||||
|
]
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _make_room(self):
|
||||||
|
"""Room in ROLE_SELECT with all 6 seats created, slot 1 eligible."""
|
||||||
|
founder, _ = User.objects.get_or_create(email=self.EMAILS[0])
|
||||||
|
room = Room.objects.create(name="Tray Card Test", owner=founder)
|
||||||
|
_fill_room_via_orm(room, self.EMAILS)
|
||||||
|
room.table_status = Room.ROLE_SELECT
|
||||||
|
room.save()
|
||||||
|
for slot in room.gate_slots.order_by("slot_number"):
|
||||||
|
TableSeat.objects.create(
|
||||||
|
room=room, gamer=slot.gamer, slot_number=slot.slot_number
|
||||||
|
)
|
||||||
|
return room
|
||||||
|
|
||||||
|
def _select_role(self):
|
||||||
|
"""Open the fan, pick the first card, confirm the guard dialog."""
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||||||
|
)
|
||||||
|
).click()
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
||||||
|
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||||||
|
self.confirm_guard()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# T1 — Portrait: role card marks first cell; tray opens then closes #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_portrait_role_card_enters_topmost_grid_square(self):
|
||||||
|
"""Portrait: after confirming a role the first .tray-cell gets
|
||||||
|
.tray-role-card; the grid still has exactly 8 cells; and the tray
|
||||||
|
opens briefly then closes once the arc-in animation completes."""
|
||||||
|
self.browser.set_window_size(390, 844)
|
||||||
|
room = self._make_room()
|
||||||
|
self.create_pre_authenticated_session("slot1@test.io")
|
||||||
|
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
|
||||||
|
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap"))
|
||||||
|
self._select_role()
|
||||||
|
|
||||||
|
# First cell receives the role card class.
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_tray_grid .tray-role-card"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.browser.execute_script("""
|
||||||
|
var grid = document.getElementById('id_tray_grid');
|
||||||
|
var card = grid.querySelector('.tray-role-card');
|
||||||
|
return {
|
||||||
|
isFirst: card !== null && card === grid.firstElementChild,
|
||||||
|
count: grid.children.length,
|
||||||
|
role: card ? card.dataset.role : null
|
||||||
|
};
|
||||||
|
""")
|
||||||
|
self.assertTrue(result["isFirst"], "Role card should be the first cell")
|
||||||
|
self.assertEqual(result["count"], 8, "Grid should still have exactly 8 cells")
|
||||||
|
self.assertTrue(result["role"], "First cell should carry data-role")
|
||||||
|
|
||||||
|
# Tray closes after the animation sequence.
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertFalse(
|
||||||
|
self.browser.execute_script("return Tray.isOpen()"),
|
||||||
|
"Tray should close after the arc-in sequence"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# T2 — Landscape: same contract in landscape #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@tag('two-browser')
|
||||||
|
def test_landscape_role_card_enters_leftmost_grid_square(self):
|
||||||
|
"""Landscape: the first .tray-cell gets .tray-role-card; grid has
|
||||||
|
8 cells; tray opens then closes."""
|
||||||
|
self.browser.set_window_size(844, 390)
|
||||||
|
room = self._make_room()
|
||||||
|
self.create_pre_authenticated_session("slot1@test.io")
|
||||||
|
self.browser.get(f"{self.live_server_url}/gameboard/room/{room.id}/gate/")
|
||||||
|
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_wrap"))
|
||||||
|
self._select_role()
|
||||||
|
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, "#id_tray_grid .tray-role-card"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = self.browser.execute_script("""
|
||||||
|
var grid = document.getElementById('id_tray_grid');
|
||||||
|
var card = grid.querySelector('.tray-role-card');
|
||||||
|
return {
|
||||||
|
isFirst: card !== null && card === grid.firstElementChild,
|
||||||
|
count: grid.children.length
|
||||||
|
};
|
||||||
|
""")
|
||||||
|
self.assertTrue(result["isFirst"], "Role card should be the first cell")
|
||||||
|
self.assertEqual(result["count"], 8, "Grid should still have exactly 8 cells")
|
||||||
|
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertFalse(
|
||||||
|
self.browser.execute_script("return Tray.isOpen()"),
|
||||||
|
"Tray should close after the arc-in sequence"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -574,11 +727,11 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
|
|||||||
)
|
)
|
||||||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||||||
|
|
||||||
# 1. Watcher loads the room — slot 1 is active on initial render
|
# 1. Watcher (slot 2) loads the room
|
||||||
self.create_pre_authenticated_session("watcher@test.io")
|
self.create_pre_authenticated_session("watcher@test.io")
|
||||||
self.browser.get(room_url)
|
self.browser.get(room_url)
|
||||||
self.wait_for(lambda: self.browser.find_element(
|
self.wait_for(lambda: self.browser.find_element(
|
||||||
By.CSS_SELECTOR, ".table-seat.active[data-slot='1']"
|
By.CSS_SELECTOR, ".card-stack[data-state='ineligible']"
|
||||||
))
|
))
|
||||||
|
|
||||||
# 2. Founder picks a role in second browser
|
# 2. Founder picks a role in second browser
|
||||||
@@ -593,16 +746,10 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
|
|||||||
self.browser2.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
self.browser2.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||||||
self.confirm_guard(browser=self.browser2)
|
self.confirm_guard(browser=self.browser2)
|
||||||
|
|
||||||
# 3. Watcher's seat arc moves to slot 2 — no page refresh
|
# 3. Watcher's turn arrives via WS — card-stack becomes eligible
|
||||||
self.wait_for(lambda: self.browser.find_element(
|
self.wait_for(lambda: self.browser.find_element(
|
||||||
By.CSS_SELECTOR, ".table-seat.active[data-slot='2']"
|
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||||||
))
|
))
|
||||||
self.assertEqual(
|
|
||||||
len(self.browser.find_elements(
|
|
||||||
By.CSS_SELECTOR, ".table-seat.active[data-slot='1']"
|
|
||||||
)),
|
|
||||||
0,
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
self.browser2.quit()
|
self.browser2.quit()
|
||||||
|
|
||||||
@@ -622,10 +769,10 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
|
|||||||
return b
|
return b
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Test 5 — Turn passes to next gamer via WebSocket after selection #
|
# Test 5 — Tray closes on turn advance (portrait) #
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
def test_turn_passes_after_selection(self):
|
def _make_turn_test_room(self):
|
||||||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||||
User.objects.get_or_create(email="friend@test.io")
|
User.objects.get_or_create(email="friend@test.io")
|
||||||
room = Room.objects.create(name="Turn Test", owner=founder)
|
room = Room.objects.create(name="Turn Test", owner=founder)
|
||||||
@@ -639,46 +786,113 @@ class RoleSelectChannelsTest(ChannelsFunctionalTest):
|
|||||||
TableSeat.objects.create(
|
TableSeat.objects.create(
|
||||||
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
room=room, gamer=slot.gamer, slot_number=slot.slot_number,
|
||||||
)
|
)
|
||||||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
return f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||||||
|
|
||||||
# 1. Founder (slot 1) — eligible
|
def test_portrait_tray_closes_on_turn_advance(self):
|
||||||
|
"""Portrait: after selecting a role the tray opens and the role card lands
|
||||||
|
in the topmost grid square. When turn_changed arrives via WS, the tray
|
||||||
|
force-closes so the next player's card-stack is not obscured."""
|
||||||
|
self.browser.set_window_size(390, 844)
|
||||||
|
room_url = self._make_turn_test_room()
|
||||||
self.create_pre_authenticated_session("founder@test.io")
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
self.browser.get(room_url)
|
self.browser.get(room_url)
|
||||||
self.wait_for(lambda: self.browser.find_element(
|
self.wait_for(lambda: self.browser.find_element(
|
||||||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||||||
))
|
))
|
||||||
|
|
||||||
# 2. Friend (slot 2) — ineligible in second browser
|
# Select a role — card lands in topmost grid square.
|
||||||
self.browser2 = self._make_browser2("friend@test.io")
|
self.browser.find_element(By.CSS_SELECTOR, ".card-stack").click()
|
||||||
try:
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
||||||
self.browser2.get(room_url)
|
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||||||
self.wait_for(lambda: self.browser2.find_element(
|
self.confirm_guard()
|
||||||
By.CSS_SELECTOR, ".card-stack[data-state='ineligible']"
|
|
||||||
))
|
|
||||||
|
|
||||||
# 3. Founder picks a role
|
# Wait for fetch .then() — card must be first child of grid.
|
||||||
self.browser.find_element(By.CSS_SELECTOR, ".card-stack").click()
|
self.wait_for(lambda: self.assertTrue(self.browser.execute_script("""
|
||||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
var card = document.querySelector('#id_tray_grid .tray-role-card');
|
||||||
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
return card !== null && card === card.parentElement.firstElementChild;
|
||||||
self.confirm_guard()
|
""")))
|
||||||
|
|
||||||
# 4. Friend's stack becomes eligible via WebSocket — no page refresh
|
# Turn advances via WS — tray must close (forceClose in handleTurnChanged).
|
||||||
self.wait_for(lambda: self.browser2.find_element(
|
self.wait_for(lambda: self.assertFalse(
|
||||||
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
self.browser.execute_script("return Tray.isOpen()"),
|
||||||
))
|
"Tray should be closed after turn advances"
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_landscape_tray_closes_on_turn_advance(self):
|
||||||
|
"""Landscape: role card at leftmost grid square; tray closes when
|
||||||
|
turn_changed arrives via WS."""
|
||||||
|
self.browser.set_window_size(844, 390)
|
||||||
|
room_url = self._make_turn_test_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(room_url)
|
||||||
|
self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||||||
|
))
|
||||||
|
|
||||||
|
self.browser.find_element(By.CSS_SELECTOR, ".card-stack").click()
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
||||||
|
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||||||
|
self.confirm_guard()
|
||||||
|
|
||||||
|
# Wait for fetch .then() — card must be first child of grid.
|
||||||
|
self.wait_for(lambda: self.assertTrue(self.browser.execute_script("""
|
||||||
|
var card = document.querySelector('#id_tray_grid .tray-role-card');
|
||||||
|
return card !== null && card === card.parentElement.firstElementChild;
|
||||||
|
""")))
|
||||||
|
|
||||||
|
# Turn advances via WS — tray must close (forceClose in handleTurnChanged).
|
||||||
|
self.wait_for(lambda: self.assertFalse(
|
||||||
|
self.browser.execute_script("return Tray.isOpen()"),
|
||||||
|
"Tray should be closed after turn advances"
|
||||||
|
))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test 7 — PICK SIGS appears + card stack removed on last role #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_pick_sigs_appears_and_card_stack_removed_on_last_role(self):
|
||||||
|
"""When the sixth and final role is confirmed, the all_roles_filled
|
||||||
|
WS event makes the PICK SIGS button visible and removes the card
|
||||||
|
stack from the DOM entirely."""
|
||||||
|
emails = [
|
||||||
|
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||||
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
|
]
|
||||||
|
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||||
|
room = Room.objects.create(name="Last Role Test", owner=founder)
|
||||||
|
_fill_room_via_orm(room, emails)
|
||||||
|
room.table_status = Room.ROLE_SELECT
|
||||||
|
room.save()
|
||||||
|
# Pre-assign 5 roles (slots 2–6); founder (slot 1) is the final picker.
|
||||||
|
pre_assigned = {2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
||||||
|
for slot in room.gate_slots.order_by("slot_number"):
|
||||||
|
TableSeat.objects.create(
|
||||||
|
room=room,
|
||||||
|
gamer=slot.gamer,
|
||||||
|
slot_number=slot.slot_number,
|
||||||
|
role=pre_assigned.get(slot.slot_number),
|
||||||
|
)
|
||||||
|
|
||||||
|
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(room_url)
|
||||||
|
self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".card-stack[data-state='eligible']"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Founder picks the last remaining role (PC — the only card in the fan).
|
||||||
|
self.browser.find_element(By.CSS_SELECTOR, ".card-stack").click()
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_role_select"))
|
||||||
|
self.browser.find_element(By.CSS_SELECTOR, "#id_role_select .card").click()
|
||||||
|
self.confirm_guard()
|
||||||
|
|
||||||
|
# PICK SIGS wrap must become visible via the all_roles_filled WS event.
|
||||||
|
self.wait_for(lambda: self.assertFalse(
|
||||||
|
self.browser.find_element(By.ID, "id_pick_sigs_wrap").get_attribute("style"),
|
||||||
|
))
|
||||||
|
# Card stack must be removed from the DOM entirely.
|
||||||
|
self.wait_for(lambda: self.assertEqual(
|
||||||
|
len(self.browser.find_elements(By.CSS_SELECTOR, ".card-stack")), 0,
|
||||||
|
))
|
||||||
|
|
||||||
# 5. Founder's stack is STILL ineligible — WS must not re-enable it
|
|
||||||
self.wait_for(lambda: self.assertEqual(
|
|
||||||
self.browser.find_element(
|
|
||||||
By.CSS_SELECTOR, ".card-stack"
|
|
||||||
).get_attribute("data-state"),
|
|
||||||
"ineligible",
|
|
||||||
))
|
|
||||||
|
|
||||||
# 6. Clicking founder's stack does not reopen the fan
|
|
||||||
self.browser.find_element(By.CSS_SELECTOR, ".card-stack").click()
|
|
||||||
self.wait_for(lambda: self.assertEqual(
|
|
||||||
len(self.browser.find_elements(By.ID, "id_role_select")), 0
|
|
||||||
))
|
|
||||||
finally:
|
|
||||||
self.browser2.quit()
|
|
||||||
|
|||||||
372
src/functional_tests/test_room_sig_select.py
Normal file
372
src/functional_tests/test_room_sig_select.py
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
from django.test import tag
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
|
from .base import FunctionalTest, ChannelsFunctionalTest
|
||||||
|
from .management.commands.create_session import create_pre_authenticated_session
|
||||||
|
from apps.applets.models import Applet
|
||||||
|
from apps.epic.models import DeckVariant, Room, TableSeat, TarotCard
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
from .test_room_role_select import _fill_room_via_orm
|
||||||
|
|
||||||
|
|
||||||
|
# ── Significator Selection ────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# After all 6 roles are revealed the room enters SIG_SELECT. Two parallel
|
||||||
|
# 18-card overlays appear (levity: PC/NC/SC; gravity: BC/EC/AC). Each polarity
|
||||||
|
# group picks simultaneously — no sequential turn order.
|
||||||
|
#
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_all_roles(room, role_order=None):
|
||||||
|
"""Assign roles to all slots, reveal them, and advance to SIG_SELECT.
|
||||||
|
Also ensures all gamers have an equipped_deck (required for sig_deck_cards)."""
|
||||||
|
if role_order is None:
|
||||||
|
role_order = SIG_SEAT_ORDER[:]
|
||||||
|
earthman, _ = DeckVariant.objects.get_or_create(
|
||||||
|
slug="earthman",
|
||||||
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||||
|
)
|
||||||
|
# Seed the 18 sig deck cards (migration data is flushed in TransactionTestCase FTs).
|
||||||
|
# _sig_unique_cards() filters arcana=MIDDLE, suits BRANDS/CROWNS/BLADES/GRAILS (Earthman).
|
||||||
|
_NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
|
||||||
|
for suit in ("BRANDS", "CROWNS", "BLADES", "GRAILS"):
|
||||||
|
for number in (11, 12, 13, 14):
|
||||||
|
TarotCard.objects.get_or_create(
|
||||||
|
deck_variant=earthman,
|
||||||
|
slug=f"{_NAME[number].lower()}-of-{suit.lower()}-em",
|
||||||
|
defaults={"arcana": "MIDDLE", "suit": suit, "number": number,
|
||||||
|
"name": f"{_NAME[number]} of {suit.capitalize()}"},
|
||||||
|
)
|
||||||
|
for number, name, slug in [
|
||||||
|
(0, "The Schiz", "the-schiz-em"),
|
||||||
|
(1, "Pope 1: Chancellor", "pope-1-chancellor-em"),
|
||||||
|
]:
|
||||||
|
TarotCard.objects.get_or_create(
|
||||||
|
deck_variant=earthman,
|
||||||
|
slug=slug,
|
||||||
|
defaults={"arcana": "MAJOR", "number": number, "name": name},
|
||||||
|
)
|
||||||
|
for slot in room.gate_slots.order_by("slot_number"):
|
||||||
|
if slot.gamer and not slot.gamer.equipped_deck:
|
||||||
|
slot.gamer.equipped_deck = earthman
|
||||||
|
slot.gamer.save(update_fields=["equipped_deck"])
|
||||||
|
TableSeat.objects.update_or_create(
|
||||||
|
room=room,
|
||||||
|
slot_number=slot.slot_number,
|
||||||
|
defaults={
|
||||||
|
"gamer": slot.gamer,
|
||||||
|
"role": role_order[slot.slot_number - 1],
|
||||||
|
"role_revealed": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
room.table_status = Room.SIG_SELECT
|
||||||
|
room.save()
|
||||||
|
|
||||||
|
|
||||||
|
class SigSelectTest(FunctionalTest):
|
||||||
|
"""Significator Selection — non-WebSocket tests."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test S1 — Seats reorder to canonical role sequence at SIG_SELECT #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_seats_display_in_pc_nc_ec_sc_ac_bc_order_after_reveal(self):
|
||||||
|
"""Slots were filled in arbitrary token-drop order; after roles are
|
||||||
|
revealed the seat portraits must appear in PC→NC→EC→SC→AC→BC order."""
|
||||||
|
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||||
|
room = Room.objects.create(name="Seat Order Test", owner=founder)
|
||||||
|
# Assign roles in reverse of canonical order so the reordering is visible
|
||||||
|
_fill_room_via_orm(room, [
|
||||||
|
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||||
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
|
])
|
||||||
|
_assign_all_roles(room, role_order=["BC", "AC", "SC", "EC", "NC", "PC"])
|
||||||
|
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||||||
|
self.browser.get(room_url)
|
||||||
|
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sig_deck"))
|
||||||
|
|
||||||
|
seats = self.browser.find_elements(By.CSS_SELECTOR, ".table-seat[data-role]")
|
||||||
|
self.assertEqual(len(seats), 6)
|
||||||
|
roles_in_order = [s.get_attribute("data-role") for s in seats]
|
||||||
|
self.assertEqual(roles_in_order, SIG_SEAT_ORDER)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@tag("channels")
|
||||||
|
class SigSelectChannelsTest(ChannelsFunctionalTest):
|
||||||
|
"""Significator Selection — WebSocket tests."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _make_browser2(self, email):
|
||||||
|
session_key = create_pre_authenticated_session(email)
|
||||||
|
options = webdriver.FirefoxOptions()
|
||||||
|
if os.environ.get("HEADLESS"):
|
||||||
|
options.add_argument("--headless")
|
||||||
|
b = webdriver.Firefox(options=options)
|
||||||
|
b.get(self.live_server_url + "/404_no_such_url/")
|
||||||
|
b.add_cookie(dict(
|
||||||
|
name=django_settings.SESSION_COOKIE_NAME,
|
||||||
|
value=session_key,
|
||||||
|
path="/",
|
||||||
|
))
|
||||||
|
return b
|
||||||
|
|
||||||
|
def _setup_sig_select_room(self):
|
||||||
|
"""Create a full SIG_SELECT room; return (room, [user_pc, user_nc, ...])."""
|
||||||
|
emails = [
|
||||||
|
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||||
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
|
]
|
||||||
|
founder, _ = User.objects.get_or_create(email=emails[0])
|
||||||
|
room = Room.objects.create(name="Cursor Colour Test", owner=founder)
|
||||||
|
gamers = _fill_room_via_orm(room, emails)
|
||||||
|
_assign_all_roles(room)
|
||||||
|
return room, gamers
|
||||||
|
|
||||||
|
# ── SC1: NC hover → PC sees mid cursor active, coloured --priYl ────────── #
|
||||||
|
|
||||||
|
@tag('channels')
|
||||||
|
def test_nc_hover_activates_mid_cursor_in_pc_browser(self):
|
||||||
|
"""
|
||||||
|
When NC (levity mid) hovers a card, PC (levity left) must see the
|
||||||
|
--mid cursor become active, coloured --priYl (rgb 255 207 52).
|
||||||
|
Verifies: WS broadcast pipeline + JS applyHover + CSS role colouring.
|
||||||
|
"""
|
||||||
|
room, gamers = self._setup_sig_select_room()
|
||||||
|
room_url = self.live_server_url + f"/gameboard/room/{room.pk}/"
|
||||||
|
|
||||||
|
# ── Browser 1: PC (founder) ───────────────────────────────────────────
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(room_url)
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||||
|
|
||||||
|
# ── Browser 2: NC (amigo) ─────────────────────────────────────────────
|
||||||
|
browser2 = self._make_browser2("amigo@test.io")
|
||||||
|
try:
|
||||||
|
browser2.get(room_url)
|
||||||
|
self.wait_for(lambda: browser2.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||||
|
|
||||||
|
# Grab the first card ID visible in browser2's deck
|
||||||
|
first_card = browser2.find_element(By.CSS_SELECTOR, ".sig-card")
|
||||||
|
card_id = first_card.get_attribute("data-card-id")
|
||||||
|
|
||||||
|
# Hover over it — triggers sendHover() → WS broadcast
|
||||||
|
from selenium.webdriver.common.action_chains import ActionChains
|
||||||
|
ActionChains(browser2).move_to_element(first_card).perform()
|
||||||
|
|
||||||
|
# ── Browser 1 should see --mid cursor go active (anchor carries class) ─
|
||||||
|
mid_cursor_sel = f'.sig-card[data-card-id="{card_id}"] .sig-cursor--mid'
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, mid_cursor_sel + ".active"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# CSS colour check: portal float has data-role="NC" → --priYl = 255, 207, 52
|
||||||
|
portal_sel = '.sig-cursor-float[data-role="NC"]'
|
||||||
|
portal_cursor = self.browser.find_element(By.CSS_SELECTOR, portal_sel)
|
||||||
|
color = self.browser.execute_script(
|
||||||
|
"return window.getComputedStyle(arguments[0]).color",
|
||||||
|
portal_cursor,
|
||||||
|
)
|
||||||
|
self.assertEqual(color, "rgb(255, 207, 52)", f"Expected --priYl colour for NC cursor, got {color}")
|
||||||
|
|
||||||
|
# ── Mouse-off: anchor class removed, portal float gone ────────────
|
||||||
|
ActionChains(browser2).move_to_element(
|
||||||
|
browser2.find_element(By.CSS_SELECTOR, ".sig-stage")
|
||||||
|
).perform()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: not self.browser.find_elements(
|
||||||
|
By.CSS_SELECTOR, mid_cursor_sel + ".active"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
browser2.quit()
|
||||||
|
|
||||||
|
# ── SC2: NC reserves → PC sees card border coloured --priYl ──────────── #
|
||||||
|
|
||||||
|
@tag('channels')
|
||||||
|
def test_nc_reservation_glows_priYl_in_pc_browser(self):
|
||||||
|
"""
|
||||||
|
When NC (levity mid) clicks OK on a card, PC must see that card's border
|
||||||
|
coloured --priYl (rgb 255 207 52) via the data-reserved-by CSS selector.
|
||||||
|
Verifies: sig_reserve view → WS broadcast → applyReservation → CSS glow.
|
||||||
|
"""
|
||||||
|
room, gamers = self._setup_sig_select_room()
|
||||||
|
room_url = self.live_server_url + f"/gameboard/room/{room.pk}/"
|
||||||
|
|
||||||
|
# ── Browser 1: PC (founder) ───────────────────────────────────────────
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(room_url)
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||||
|
|
||||||
|
# ── Browser 2: NC (amigo) ─────────────────────────────────────────────
|
||||||
|
browser2 = self._make_browser2("amigo@test.io")
|
||||||
|
try:
|
||||||
|
browser2.get(room_url)
|
||||||
|
self.wait_for(lambda: browser2.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||||
|
|
||||||
|
# Get first card in B2's deck
|
||||||
|
first_card = browser2.find_element(By.CSS_SELECTOR, ".sig-card")
|
||||||
|
card_id = first_card.get_attribute("data-card-id")
|
||||||
|
|
||||||
|
# Click card body → .sig-focused → OK button appears
|
||||||
|
from selenium.webdriver.common.action_chains import ActionChains
|
||||||
|
ActionChains(browser2).move_to_element(first_card).perform()
|
||||||
|
first_card.click()
|
||||||
|
|
||||||
|
ok_btn = self.wait_for(
|
||||||
|
lambda: browser2.find_element(By.CSS_SELECTOR, ".sig-focused .sig-ok-btn")
|
||||||
|
)
|
||||||
|
ok_btn.click()
|
||||||
|
|
||||||
|
# ── B1 should see the card's border turn --priYl ──────────────────
|
||||||
|
reserved_card_sel = f'.sig-card[data-card-id="{card_id}"]'
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, reserved_card_sel + '[data-reserved-by="NC"]'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
reserved_card = self.browser.find_element(By.CSS_SELECTOR, reserved_card_sel)
|
||||||
|
box_shadow = self.browser.execute_script(
|
||||||
|
"return window.getComputedStyle(arguments[0]).boxShadow",
|
||||||
|
reserved_card,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"255, 207, 52", box_shadow,
|
||||||
|
f"Expected --priYl (255,207,52) in box-shadow for NC reservation, got {box_shadow}",
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
browser2.quit()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── Polarity theming: qualifier text + no correspondence ─────────────────────
|
||||||
|
|
||||||
|
class SigSelectThemeTest(FunctionalTest):
|
||||||
|
"""Polarity-qualifier display (Graven/Leavened) and correspondence suppression.
|
||||||
|
No WebSocket needed — stage updates are local; uses plain FunctionalTest."""
|
||||||
|
|
||||||
|
EMAILS = [
|
||||||
|
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||||
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
|
]
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.browser.set_window_size(800, 1200)
|
||||||
|
Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
|
||||||
|
Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"})
|
||||||
|
|
||||||
|
def _setup_sig_room(self):
|
||||||
|
founder, _ = User.objects.get_or_create(email=self.EMAILS[0])
|
||||||
|
room = Room.objects.create(name="Theme Test", owner=founder)
|
||||||
|
_fill_room_via_orm(room, self.EMAILS)
|
||||||
|
_assign_all_roles(room)
|
||||||
|
return room
|
||||||
|
|
||||||
|
def _hover_card(self, css):
|
||||||
|
from selenium.webdriver.common.action_chains import ActionChains
|
||||||
|
card = self.browser.find_element(By.CSS_SELECTOR, css)
|
||||||
|
ActionChains(self.browser).move_to_element(card).perform()
|
||||||
|
return card
|
||||||
|
|
||||||
|
# ── ST1: Levity (Leavened) qualifier ──────────────────────────────────── #
|
||||||
|
|
||||||
|
def test_levity_non_major_card_shows_leavened_above(self):
|
||||||
|
"""Hovering a non-major card in the levity overlay shows 'Leavened' in
|
||||||
|
qualifier-above and nothing in qualifier-below."""
|
||||||
|
room = self._setup_sig_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io") # PC = levity
|
||||||
|
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||||
|
|
||||||
|
self._hover_card('.sig-card[data-arcana="Middle Arcana"]')
|
||||||
|
|
||||||
|
above = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-above")
|
||||||
|
)
|
||||||
|
self.assertEqual(above.text, "Leavened")
|
||||||
|
below = self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-below")
|
||||||
|
self.assertEqual(below.text, "")
|
||||||
|
|
||||||
|
def test_levity_major_card_shows_leavened_below(self):
|
||||||
|
"""Hovering a major arcana card in the levity overlay shows 'Leavened' in
|
||||||
|
qualifier-below and nothing in qualifier-above."""
|
||||||
|
room = self._setup_sig_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io") # PC = levity
|
||||||
|
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||||
|
|
||||||
|
self._hover_card('.sig-card[data-arcana="Major Arcana"]')
|
||||||
|
|
||||||
|
below = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-below")
|
||||||
|
)
|
||||||
|
self.assertEqual(below.text, "Leavened")
|
||||||
|
above = self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-above")
|
||||||
|
self.assertEqual(above.text, "")
|
||||||
|
|
||||||
|
# ── ST2: Gravity (Graven) qualifier ───────────────────────────────────── #
|
||||||
|
|
||||||
|
def test_gravity_non_major_card_shows_graven_above(self):
|
||||||
|
"""EC (bud) sees the gravity overlay; hovering a non-major card shows 'Graven'."""
|
||||||
|
room = self._setup_sig_room()
|
||||||
|
self.create_pre_authenticated_session("bud@test.io") # EC = gravity
|
||||||
|
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||||
|
|
||||||
|
self._hover_card('.sig-card[data-arcana="Middle Arcana"]')
|
||||||
|
|
||||||
|
above = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-above")
|
||||||
|
)
|
||||||
|
self.assertEqual(above.text, "Graven")
|
||||||
|
|
||||||
|
# ── ST3: Correspondence not shown ─────────────────────────────────────── #
|
||||||
|
|
||||||
|
def test_correspondence_not_shown_in_sig_select(self):
|
||||||
|
"""The Minchiate-equivalence field must always be blank on the stage card."""
|
||||||
|
room = self._setup_sig_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||||
|
|
||||||
|
# Hover any card — correspondence should remain empty regardless
|
||||||
|
self._hover_card(".sig-card")
|
||||||
|
self.wait_for(lambda: self.browser.find_element(
|
||||||
|
By.CSS_SELECTOR, ".sig-stage-card"
|
||||||
|
))
|
||||||
|
corr = self.browser.find_element(By.CSS_SELECTOR, ".fan-card-correspondence")
|
||||||
|
self.assertEqual(corr.text, "")
|
||||||
380
src/functional_tests/test_room_tray.py
Normal file
380
src/functional_tests/test_room_tray.py
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from django.test import tag
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
|
from .base import FunctionalTest
|
||||||
|
from .test_room_role_select import _fill_room_via_orm
|
||||||
|
from .test_room_sig_select import _assign_all_roles
|
||||||
|
from apps.epic.models import Room
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
# ── Seat Tray ────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# The Tray is a per-seat, per-room slide-out panel anchored to the right edge
|
||||||
|
# of the viewport. #id_tray_btn is a drawer-handle-shaped button: a circle
|
||||||
|
# with an icon (the "ivory centre") with decorative lines curving from its top
|
||||||
|
# and bottom to the right edge of the screen.
|
||||||
|
#
|
||||||
|
# Behaviour:
|
||||||
|
# - Closed by default; tray panel (#id_tray) is not visible.
|
||||||
|
# - Clicking the button while closed: wobbles the handle (adds "wobble"
|
||||||
|
# class) but does NOT open the tray.
|
||||||
|
# - Dragging the button leftward: reveals the tray.
|
||||||
|
# - Clicking the button while open: slides the tray closed.
|
||||||
|
# - On page reload: tray always starts closed (JS in-memory only).
|
||||||
|
#
|
||||||
|
# Contents (populated in later sprints): Role card, Significator, Celtic Cross
|
||||||
|
# draw, natus wheel, committed dice/cards for this table.
|
||||||
|
#
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TrayTest(FunctionalTest):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Portrait viewport for T1–T5 (768×1024). Use _make_browser so
|
||||||
|
# headless CI gets --width/--height args and the CSS orientation
|
||||||
|
# media query is correct from first paint.
|
||||||
|
self.browser = self._make_browser(768, 1024)
|
||||||
|
self.test_server = None
|
||||||
|
from apps.applets.models import Applet
|
||||||
|
Applet.objects.get_or_create(slug="new-note", defaults={"name": "New Note"})
|
||||||
|
|
||||||
|
def _switch_to_landscape(self):
|
||||||
|
"""Recreate the browser, navigate to about:blank, then resize to
|
||||||
|
900×500 and wait until window.innerWidth > window.innerHeight confirms
|
||||||
|
the CSS orientation media query will fire correctly on the next page."""
|
||||||
|
self.browser.quit()
|
||||||
|
self.browser = self._make_browser(900, 500)
|
||||||
|
self.browser.get('about:blank')
|
||||||
|
self.browser.set_window_size(900, 500)
|
||||||
|
time.sleep(0.5) # allow Firefox to flush the resize before navigating
|
||||||
|
self.wait_for(lambda: self.assertTrue(
|
||||||
|
self.browser.execute_script(
|
||||||
|
'return window.innerWidth > window.innerHeight'
|
||||||
|
)
|
||||||
|
))
|
||||||
|
|
||||||
|
def _simulate_drag(self, btn, offset_x):
|
||||||
|
"""Dispatch JS pointer events directly — more reliable than GeckoDriver drag."""
|
||||||
|
start_x = btn.rect['x'] + btn.rect['width'] / 2
|
||||||
|
end_x = start_x + offset_x
|
||||||
|
self.browser.execute_script("""
|
||||||
|
var btn = arguments[0], startX = arguments[1], endX = arguments[2];
|
||||||
|
btn.dispatchEvent(new PointerEvent("pointerdown", {clientX: startX, bubbles: true}));
|
||||||
|
document.dispatchEvent(new PointerEvent("pointermove", {clientX: endX, bubbles: true}));
|
||||||
|
document.dispatchEvent(new PointerEvent("pointerup", {clientX: endX, bubbles: true}));
|
||||||
|
""", btn, start_x, end_x)
|
||||||
|
|
||||||
|
def _simulate_drag_y(self, btn, offset_y):
|
||||||
|
"""Dispatch JS pointer events on the Y axis for landscape drag tests."""
|
||||||
|
start_y = btn.rect['y'] + btn.rect['height'] / 2
|
||||||
|
end_y = start_y + offset_y
|
||||||
|
self.browser.execute_script("""
|
||||||
|
var btn = arguments[0], startY = arguments[1], endY = arguments[2];
|
||||||
|
btn.dispatchEvent(new PointerEvent("pointerdown", {clientY: startY, clientX: 0, bubbles: true}));
|
||||||
|
document.dispatchEvent(new PointerEvent("pointermove", {clientY: endY, clientX: 0, bubbles: true}));
|
||||||
|
document.dispatchEvent(new PointerEvent("pointerup", {clientY: endY, clientX: 0, bubbles: true}));
|
||||||
|
""", btn, start_y, end_y)
|
||||||
|
|
||||||
|
def _make_role_select_room(self, founder_email="founder@test.io"):
|
||||||
|
from apps.epic.models import TableSeat
|
||||||
|
founder, _ = User.objects.get_or_create(email=founder_email)
|
||||||
|
room = Room.objects.create(name="Tray Test Room", owner=founder)
|
||||||
|
emails = [founder_email, "nc@test.io", "bud@test.io",
|
||||||
|
"pal@test.io", "dude@test.io", "bro@test.io"]
|
||||||
|
_fill_room_via_orm(room, emails)
|
||||||
|
room.table_status = Room.ROLE_SELECT
|
||||||
|
room.save()
|
||||||
|
for i, email in enumerate(emails, start=1):
|
||||||
|
gamer, _ = User.objects.get_or_create(email=email)
|
||||||
|
TableSeat.objects.get_or_create(room=room, gamer=gamer, slot_number=i)
|
||||||
|
return room
|
||||||
|
|
||||||
|
def _make_sig_select_room(self, founder_email="founder@test.io"):
|
||||||
|
founder, _ = User.objects.get_or_create(email=founder_email)
|
||||||
|
room = Room.objects.create(name="Tray Test Room", owner=founder)
|
||||||
|
_fill_room_via_orm(room, [
|
||||||
|
founder_email, "nc@test.io", "bud@test.io",
|
||||||
|
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||||
|
])
|
||||||
|
_assign_all_roles(room)
|
||||||
|
return room
|
||||||
|
|
||||||
|
def _room_url(self, room):
|
||||||
|
return f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test T1 — tray button is present and anchored to the right edge #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_tray_btn_is_present_on_room_page(self):
|
||||||
|
room = self._make_sig_select_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self._room_url(room))
|
||||||
|
|
||||||
|
btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_tray_btn")
|
||||||
|
)
|
||||||
|
self.assertTrue(btn.is_displayed())
|
||||||
|
|
||||||
|
# Button should be anchored near the right edge of the viewport
|
||||||
|
vp_width = self.browser.execute_script("return window.innerWidth")
|
||||||
|
btn_right = btn.location["x"] + btn.size["width"]
|
||||||
|
self.assertGreater(btn_right, vp_width * 0.8)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test T2 — tray is closed by default; clicking wobbles the handle #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_tray_is_closed_by_default_and_click_wobbles(self):
|
||||||
|
room = self._make_sig_select_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self._room_url(room))
|
||||||
|
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
|
||||||
|
|
||||||
|
# Tray panel not visible when closed
|
||||||
|
tray = self.browser.find_element(By.ID, "id_tray")
|
||||||
|
self.assertFalse(tray.is_displayed())
|
||||||
|
|
||||||
|
# Clicking the closed btn adds a wobble class to the wrap.
|
||||||
|
# Use a MutationObserver to capture the transient class change — in CI
|
||||||
|
# headless Firefox the 0.45s animation may complete before the first
|
||||||
|
# wait_for poll (0.5s), causing a false miss.
|
||||||
|
self.browser.execute_script("""
|
||||||
|
window._trayWobbled = false;
|
||||||
|
var wrap = document.getElementById('id_tray_wrap');
|
||||||
|
var obs = new MutationObserver(function(muts) {
|
||||||
|
muts.forEach(function(m) {
|
||||||
|
if (m.type === 'attributes' && m.attributeName === 'class') {
|
||||||
|
if (m.target.classList.contains('wobble')) {
|
||||||
|
window._trayWobbled = true;
|
||||||
|
obs.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
obs.observe(wrap, {attributes: true, attributeFilter: ['class']});
|
||||||
|
""")
|
||||||
|
self.browser.find_element(By.ID, "id_tray_btn").click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertTrue(
|
||||||
|
self.browser.execute_script("return window._trayWobbled;")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Tray still not visible — a click alone must not open it
|
||||||
|
self.assertFalse(tray.is_displayed())
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test T3 — dragging tray btn leftward opens the tray #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_dragging_tray_btn_left_opens_tray(self):
|
||||||
|
room = self._make_sig_select_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self._room_url(room))
|
||||||
|
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
|
||||||
|
tray = self.browser.find_element(By.ID, "id_tray")
|
||||||
|
self.assertFalse(tray.is_displayed())
|
||||||
|
|
||||||
|
self._simulate_drag(btn, -300)
|
||||||
|
|
||||||
|
self.wait_for(lambda: self.assertTrue(tray.is_displayed()))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test T4 — clicking btn while tray is open slides it closed #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_clicking_open_tray_btn_closes_tray(self):
|
||||||
|
room = self._make_sig_select_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self._room_url(room))
|
||||||
|
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
|
||||||
|
self._simulate_drag(btn, -300)
|
||||||
|
|
||||||
|
tray = self.browser.find_element(By.ID, "id_tray")
|
||||||
|
self.wait_for(lambda: self.assertTrue(tray.is_displayed()))
|
||||||
|
|
||||||
|
self.browser.find_element(By.ID, "id_tray_btn").click()
|
||||||
|
self.wait_for(lambda: self.assertFalse(tray.is_displayed()))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test T5 — tray reverts to closed on page reload #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_tray_reverts_to_closed_on_reload(self):
|
||||||
|
room = self._make_sig_select_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
room_url = self._room_url(room)
|
||||||
|
self.browser.get(room_url)
|
||||||
|
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
|
||||||
|
self._simulate_drag(btn, -300)
|
||||||
|
|
||||||
|
tray = self.browser.find_element(By.ID, "id_tray")
|
||||||
|
self.wait_for(lambda: self.assertTrue(tray.is_displayed()))
|
||||||
|
|
||||||
|
# Reload — tray must start closed regardless of previous state
|
||||||
|
self.browser.get(room_url)
|
||||||
|
self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
|
||||||
|
tray = self.browser.find_element(By.ID, "id_tray")
|
||||||
|
self.assertFalse(tray.is_displayed())
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test T6 — landscape: tray btn is near the top edge of the viewport #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@tag('two-browser')
|
||||||
|
def test_tray_btn_anchored_near_top_in_landscape(self):
|
||||||
|
room = self._make_sig_select_room()
|
||||||
|
self._switch_to_landscape()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self._room_url(room))
|
||||||
|
|
||||||
|
btn = self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.ID, "id_tray_btn")
|
||||||
|
)
|
||||||
|
self.assertTrue(btn.is_displayed())
|
||||||
|
|
||||||
|
# In landscape the handle sits at the top of the content area;
|
||||||
|
# btn bottom should be within the top 40% of the viewport.
|
||||||
|
vh = self.browser.execute_script("return window.innerHeight")
|
||||||
|
btn_bottom = btn.location["y"] + btn.size["height"]
|
||||||
|
self.assertLess(btn_bottom, vh * 0.4)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test T7 — landscape: dragging btn downward opens the tray #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@tag('two-browser')
|
||||||
|
def test_dragging_tray_btn_down_opens_tray_in_landscape(self):
|
||||||
|
room = self._make_sig_select_room()
|
||||||
|
self._switch_to_landscape()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self._room_url(room))
|
||||||
|
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
|
||||||
|
# In landscape, #id_tray is always display:block; position controls visibility.
|
||||||
|
# Use Tray.isOpen() to check logical state.
|
||||||
|
self.assertFalse(self.browser.execute_script("return Tray.isOpen()"))
|
||||||
|
|
||||||
|
self._simulate_drag_y(btn, 300)
|
||||||
|
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()"))
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test T8 — portrait: 1 column × 8 rows of square cells #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@unittest.skip("portrait grid layout flaky in CI headless Firefox — revisit")
|
||||||
|
@tag('two-browser')
|
||||||
|
def test_tray_grid_is_1_column_by_8_rows_in_portrait(self):
|
||||||
|
room = self._make_role_select_room()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self._room_url(room))
|
||||||
|
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
|
||||||
|
self._simulate_drag(btn, -300)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertTrue(
|
||||||
|
self.browser.find_element(By.ID, "id_tray").is_displayed()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell")
|
||||||
|
self.assertEqual(len(cells), 8)
|
||||||
|
|
||||||
|
# 8 explicit rows set via grid-template-rows
|
||||||
|
row_count = self.browser.execute_script("""
|
||||||
|
var s = getComputedStyle(document.getElementById('id_tray_grid'));
|
||||||
|
return s.gridTemplateRows.trim().split(/\\s+/).length;
|
||||||
|
""")
|
||||||
|
self.assertEqual(row_count, 8)
|
||||||
|
|
||||||
|
# All 8 cells share the same x position — one column only
|
||||||
|
xs = {round(c.location['x']) for c in cells}
|
||||||
|
self.assertEqual(len(xs), 1)
|
||||||
|
|
||||||
|
# Cells are square
|
||||||
|
cell = cells[0]
|
||||||
|
self.assertAlmostEqual(cell.size['width'], cell.size['height'], delta=2)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test T9 — landscape: 8 columns × 1 row of square cells #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# T9a — column/row count (structure)
|
||||||
|
@unittest.skip("landscape grid layout flaky in CI headless Firefox — revisit")
|
||||||
|
@tag('two-browser')
|
||||||
|
def test_tray_grid_is_8_columns_by_1_row_in_landscape(self):
|
||||||
|
room = self._make_sig_select_room()
|
||||||
|
self._switch_to_landscape()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self._room_url(room))
|
||||||
|
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
|
||||||
|
self._simulate_drag_y(btn, 300)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()"))
|
||||||
|
)
|
||||||
|
|
||||||
|
cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell")
|
||||||
|
self.assertEqual(len(cells), 8)
|
||||||
|
|
||||||
|
# 8 explicit columns set via grid-template-columns
|
||||||
|
col_count = self.browser.execute_script("""
|
||||||
|
var s = getComputedStyle(document.getElementById('id_tray_grid'));
|
||||||
|
return s.gridTemplateColumns.trim().split(/\\s+/).length;
|
||||||
|
""")
|
||||||
|
self.assertEqual(col_count, 8)
|
||||||
|
|
||||||
|
# All 8 cells share the same y position — one row only
|
||||||
|
ys = {round(c.location['y']) for c in cells}
|
||||||
|
self.assertEqual(len(ys), 1)
|
||||||
|
|
||||||
|
# Cells are square
|
||||||
|
cell = cells[0]
|
||||||
|
self.assertAlmostEqual(cell.size['width'], cell.size['height'], delta=2)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Test T9b — landscape: all 8 cells visible within the tray interior #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
@unittest.skip("landscape cell bounds flaky in CI headless Firefox — revisit with T9a")
|
||||||
|
@tag('two-browser')
|
||||||
|
def test_landscape_tray_all_8_cells_visible(self):
|
||||||
|
room = self._make_sig_select_room()
|
||||||
|
self._switch_to_landscape()
|
||||||
|
self.create_pre_authenticated_session("founder@test.io")
|
||||||
|
self.browser.get(self._room_url(room))
|
||||||
|
|
||||||
|
btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_tray_btn"))
|
||||||
|
self._simulate_drag_y(btn, 300)
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertTrue(self.browser.execute_script("return Tray.isOpen()"))
|
||||||
|
)
|
||||||
|
|
||||||
|
tray = self.browser.find_element(By.ID, "id_tray")
|
||||||
|
cells = self.browser.find_elements(By.CSS_SELECTOR, "#id_tray_grid .tray-cell")
|
||||||
|
self.assertEqual(len(cells), 8)
|
||||||
|
|
||||||
|
tray_right = tray.location['x'] + tray.size['width']
|
||||||
|
tray_bottom = tray.location['y'] + tray.size['height']
|
||||||
|
|
||||||
|
# Each cell must fit within the tray interior (2px rounding slack)
|
||||||
|
for cell in cells:
|
||||||
|
self.assertLessEqual(
|
||||||
|
cell.location['x'] + cell.size['width'], tray_right + 2,
|
||||||
|
msg="Cell overflows tray right edge"
|
||||||
|
)
|
||||||
|
self.assertLessEqual(
|
||||||
|
cell.location['y'] + cell.size['height'], tray_bottom + 2,
|
||||||
|
msg="Cell overflows tray bottom edge"
|
||||||
|
)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.test import tag
|
||||||
from selenium import webdriver
|
from selenium import webdriver
|
||||||
from selenium.webdriver.common.by import By
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ def quit_if_possible(browser):
|
|||||||
|
|
||||||
# Test mdls
|
# Test mdls
|
||||||
class SharingTest(FunctionalTest):
|
class SharingTest(FunctionalTest):
|
||||||
|
@tag("two-browser")
|
||||||
def test_can_share_a_note_with_another_user(self):
|
def test_can_share_a_note_with_another_user(self):
|
||||||
self.create_pre_authenticated_session("disco@test.io")
|
self.create_pre_authenticated_session("disco@test.io")
|
||||||
disco_browser = self.browser
|
disco_browser = self.browser
|
||||||
|
|||||||
21
src/static/tests/LICENSE
Normal file
21
src/static/tests/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
Copyright (c) 2008-2019 Pivotal Labs
|
||||||
|
Copyright (c) 2008-2026 The Jasmine developers
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of this software and associated documentation files (the
|
||||||
|
"Software"), to deal in the Software without restriction, including
|
||||||
|
without limitation the rights to use, copy, modify, merge, publish,
|
||||||
|
distribute, sublicense, and/or sell copies of the Software, and to
|
||||||
|
permit persons to whom the Software is furnished to do so, subject to
|
||||||
|
the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be
|
||||||
|
included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||||
|
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||||
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
696
src/static/tests/RoleSelectSpec.js
Normal file
696
src/static/tests/RoleSelectSpec.js
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
describe("RoleSelect", () => {
|
||||||
|
let testDiv;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
RoleSelect._testReset(); // zero _placeCardDelay; clear _animationPending/_pendingTurnChange
|
||||||
|
testDiv = document.createElement("div");
|
||||||
|
testDiv.innerHTML = `
|
||||||
|
<div class="room-page"
|
||||||
|
data-select-role-url="/epic/room/test-uuid/select-role">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(testDiv);
|
||||||
|
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
||||||
|
Promise.resolve({ ok: true })
|
||||||
|
);
|
||||||
|
// Default stub: auto-confirm so existing card-click tests pass unchanged.
|
||||||
|
// The click-guard integration describe overrides this with a capturing spy.
|
||||||
|
window.showGuard = (_anchor, _msg, onConfirm) => onConfirm && onConfirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
RoleSelect.closeFan();
|
||||||
|
testDiv.remove();
|
||||||
|
delete window.showGuard;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// openFan() //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
describe("openFan()", () => {
|
||||||
|
it("creates .role-select-backdrop in the DOM", () => {
|
||||||
|
RoleSelect.openFan();
|
||||||
|
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates #id_role_select inside the backdrop", () => {
|
||||||
|
RoleSelect.openFan();
|
||||||
|
expect(document.getElementById("id_role_select")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders exactly 6 .card elements", () => {
|
||||||
|
RoleSelect.openFan();
|
||||||
|
const cards = document.querySelectorAll("#id_role_select .card");
|
||||||
|
expect(cards.length).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open a second backdrop if already open", () => {
|
||||||
|
RoleSelect.openFan();
|
||||||
|
RoleSelect.openFan();
|
||||||
|
expect(document.querySelectorAll(".role-select-backdrop").length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// closeFan() //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
describe("closeFan()", () => {
|
||||||
|
it("removes .role-select-backdrop from the DOM", () => {
|
||||||
|
RoleSelect.openFan();
|
||||||
|
RoleSelect.closeFan();
|
||||||
|
expect(document.querySelector(".role-select-backdrop")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes #id_role_select from the DOM", () => {
|
||||||
|
RoleSelect.openFan();
|
||||||
|
RoleSelect.closeFan();
|
||||||
|
expect(document.getElementById("id_role_select")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw if no fan is open", () => {
|
||||||
|
expect(() => RoleSelect.closeFan()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// Card interactions //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
describe("card interactions", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
RoleSelect.openFan();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mouseenter adds .flipped to the card", () => {
|
||||||
|
const card = document.querySelector("#id_role_select .card");
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter"));
|
||||||
|
expect(card.classList.contains("flipped")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mouseleave removes .flipped from the card", () => {
|
||||||
|
const card = document.querySelector("#id_role_select .card");
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter"));
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseleave"));
|
||||||
|
expect(card.classList.contains("flipped")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a card closes the fan", () => {
|
||||||
|
const card = document.querySelector("#id_role_select .card");
|
||||||
|
card.click();
|
||||||
|
expect(document.getElementById("id_role_select")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking a card POSTs to the select_role URL", () => {
|
||||||
|
const card = document.querySelector("#id_role_select .card");
|
||||||
|
card.click();
|
||||||
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
|
"/epic/room/test-uuid/select-role",
|
||||||
|
jasmine.objectContaining({ method: "POST" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// Backdrop click //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
describe("backdrop click", () => {
|
||||||
|
it("closes the fan", () => {
|
||||||
|
RoleSelect.openFan();
|
||||||
|
document.querySelector(".role-select-backdrop").click();
|
||||||
|
expect(document.getElementById("id_role_select")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// room:all_roles_filled event //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
describe("room:all_roles_filled event", () => {
|
||||||
|
let pickSigsWrap;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
pickSigsWrap = document.createElement("div");
|
||||||
|
pickSigsWrap.id = "id_pick_sigs_wrap";
|
||||||
|
pickSigsWrap.style.display = "none";
|
||||||
|
testDiv.appendChild(pickSigsWrap);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows #id_pick_sigs_wrap", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:all_roles_filled", { detail: {} }));
|
||||||
|
expect(pickSigsWrap.style.display).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// room:sig_select_started event //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
describe("room:sig_select_started event", () => {
|
||||||
|
let reloadCalled;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
reloadCalled = false;
|
||||||
|
RoleSelect.setReload(() => { reloadCalled = true; });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
RoleSelect.setReload(() => { window.location.reload(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("triggers a page reload", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_select_started", { detail: {} }));
|
||||||
|
expect(reloadCalled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// room:turn_changed event //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
describe("room:turn_changed event", () => {
|
||||||
|
let stack, trayWrap;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Six table seats, slot 1 starts active
|
||||||
|
for (let i = 1; i <= 6; i++) {
|
||||||
|
const seat = document.createElement("div");
|
||||||
|
seat.className = "table-seat" + (i === 1 ? " active" : "");
|
||||||
|
seat.dataset.slot = String(i);
|
||||||
|
seat.innerHTML = '<div class="seat-card-arc"></div>';
|
||||||
|
testDiv.appendChild(seat);
|
||||||
|
}
|
||||||
|
stack = document.createElement("div");
|
||||||
|
stack.className = "card-stack";
|
||||||
|
stack.dataset.state = "ineligible";
|
||||||
|
stack.dataset.userSlots = "1";
|
||||||
|
stack.dataset.starterRoles = "";
|
||||||
|
testDiv.appendChild(stack);
|
||||||
|
|
||||||
|
trayWrap = document.createElement("div");
|
||||||
|
trayWrap.id = "id_tray_wrap";
|
||||||
|
trayWrap.className = "role-select-phase";
|
||||||
|
testDiv.appendChild(trayWrap);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls Tray.forceClose() on turn change", () => {
|
||||||
|
spyOn(Tray, "forceClose");
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2 }
|
||||||
|
}));
|
||||||
|
expect(Tray.forceClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears .active from all seats on turn change", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2 }
|
||||||
|
}));
|
||||||
|
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-adds role-select-phase to tray wrap on turn change", () => {
|
||||||
|
trayWrap.classList.remove("role-select-phase"); // simulate it was shown
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2 }
|
||||||
|
}));
|
||||||
|
expect(trayWrap.classList.contains("role-select-phase")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .active from the previously active seat", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2 }
|
||||||
|
}));
|
||||||
|
expect(
|
||||||
|
testDiv.querySelector(".table-seat[data-slot='1']").classList.contains("active")
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets data-state to eligible when active_slot matches user slot", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 1 }
|
||||||
|
}));
|
||||||
|
expect(stack.dataset.state).toBe("eligible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets data-state to ineligible when active_slot does not match", () => {
|
||||||
|
stack.dataset.state = "eligible";
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2 }
|
||||||
|
}));
|
||||||
|
expect(stack.dataset.state).toBe("ineligible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking stack opens fan when newly eligible", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 1 }
|
||||||
|
}));
|
||||||
|
stack.click();
|
||||||
|
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking stack does not open fan when ineligible", () => {
|
||||||
|
// Make eligible first (adds listener), then flip back to ineligible
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 1 }
|
||||||
|
}));
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2 }
|
||||||
|
}));
|
||||||
|
stack.click();
|
||||||
|
expect(document.querySelector(".role-select-backdrop")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates seat icon to fa-circle-check when role appears in starter_roles", () => {
|
||||||
|
const seat = document.createElement("div");
|
||||||
|
seat.className = "table-seat";
|
||||||
|
seat.dataset.role = "PC";
|
||||||
|
const ban = document.createElement("i");
|
||||||
|
ban.className = "position-status-icon fa-solid fa-ban";
|
||||||
|
seat.appendChild(ban);
|
||||||
|
testDiv.appendChild(seat);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: ["PC"] }
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(seat.querySelector(".fa-ban")).toBeNull();
|
||||||
|
expect(seat.querySelector(".fa-circle-check")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds role-confirmed to seat when role appears in starter_roles", () => {
|
||||||
|
const seat = document.createElement("div");
|
||||||
|
seat.className = "table-seat";
|
||||||
|
seat.dataset.role = "PC";
|
||||||
|
testDiv.appendChild(seat);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: ["PC"] }
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(seat.classList.contains("role-confirmed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves seat icon as fa-ban when role not in starter_roles", () => {
|
||||||
|
const seat = document.createElement("div");
|
||||||
|
seat.className = "table-seat";
|
||||||
|
seat.dataset.role = "NC";
|
||||||
|
const ban = document.createElement("i");
|
||||||
|
ban.className = "position-status-icon fa-solid fa-ban";
|
||||||
|
seat.appendChild(ban);
|
||||||
|
testDiv.appendChild(seat);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: ["PC"] }
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(seat.querySelector(".fa-ban")).not.toBeNull();
|
||||||
|
expect(seat.querySelector(".fa-circle-check")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds role-assigned to slot-1 circle when 1 role assigned", () => {
|
||||||
|
const circle = document.createElement("div");
|
||||||
|
circle.className = "gate-slot filled";
|
||||||
|
circle.dataset.slot = "1";
|
||||||
|
testDiv.appendChild(circle);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: ["PC"] }
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(circle.classList.contains("role-assigned")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves slot-2 circle visible when only 1 role assigned", () => {
|
||||||
|
const circle = document.createElement("div");
|
||||||
|
circle.className = "gate-slot filled";
|
||||||
|
circle.dataset.slot = "2";
|
||||||
|
testDiv.appendChild(circle);
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: ["PC"] }
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(circle.classList.contains("role-assigned")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates data-active-slot on card stack to the new active slot", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: [] }
|
||||||
|
}));
|
||||||
|
expect(stack.dataset.activeSlot).toBe("2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// selectRole slot-circle fade-out //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
describe("selectRole() slot-circle behaviour", () => {
|
||||||
|
let circle, stack;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Gate-slot circle for slot 1 (active turn)
|
||||||
|
circle = document.createElement("div");
|
||||||
|
circle.className = "gate-slot filled";
|
||||||
|
circle.dataset.slot = "1";
|
||||||
|
testDiv.appendChild(circle);
|
||||||
|
|
||||||
|
// Card stack with active-slot=1 so selectRole() knows which circle to hide
|
||||||
|
stack = document.createElement("div");
|
||||||
|
stack.className = "card-stack";
|
||||||
|
stack.dataset.state = "eligible";
|
||||||
|
stack.dataset.starterRoles = "";
|
||||||
|
stack.dataset.userSlots = "1";
|
||||||
|
stack.dataset.activeSlot = "1";
|
||||||
|
testDiv.appendChild(stack);
|
||||||
|
|
||||||
|
spyOn(Tray, "placeCard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds role-assigned to the active slot's circle immediately on confirm", () => {
|
||||||
|
RoleSelect.openFan();
|
||||||
|
document.querySelector("#id_role_select .card").click();
|
||||||
|
expect(circle.classList.contains("role-assigned")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// Tray card placement after successful role selection //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// The tray-role-card is created in the fetch .then() callback, so //
|
||||||
|
// these tests are async — await Promise.resolve() flushes the //
|
||||||
|
// microtask queue before asserting. //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
describe("tray card after successful role selection", () => {
|
||||||
|
let guardConfirm, trayWrap;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
trayWrap = document.createElement("div");
|
||||||
|
trayWrap.id = "id_tray_wrap";
|
||||||
|
trayWrap.className = "role-select-phase";
|
||||||
|
testDiv.appendChild(trayWrap);
|
||||||
|
|
||||||
|
// Spy on Tray.placeCard: call the onComplete callback immediately.
|
||||||
|
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
|
||||||
|
if (cb) cb();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capturing guard spy — holds onConfirm so we can fire it per-test
|
||||||
|
window.showGuard = jasmine.createSpy("showGuard").and.callFake(
|
||||||
|
(anchor, message, onConfirm) => { guardConfirm = onConfirm; }
|
||||||
|
);
|
||||||
|
|
||||||
|
RoleSelect.openFan();
|
||||||
|
document.querySelector("#id_role_select .card").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls Tray.placeCard() on success", async () => {
|
||||||
|
guardConfirm();
|
||||||
|
await Promise.resolve(); // flush fetch .then()
|
||||||
|
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
|
||||||
|
expect(Tray.placeCard).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the role code string to Tray.placeCard", async () => {
|
||||||
|
guardConfirm();
|
||||||
|
await Promise.resolve(); // flush fetch .then()
|
||||||
|
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay setTimeout
|
||||||
|
const roleCode = Tray.placeCard.calls.mostRecent().args[0];
|
||||||
|
expect(typeof roleCode).toBe("string");
|
||||||
|
expect(roleCode.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call Tray.placeCard() on server rejection", async () => {
|
||||||
|
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
||||||
|
Promise.resolve({ ok: false })
|
||||||
|
);
|
||||||
|
guardConfirm();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(Tray.placeCard).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes role-select-phase from tray wrap on successful pick", async () => {
|
||||||
|
guardConfirm();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(trayWrap.classList.contains("role-select-phase")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds role-confirmed class to the seated position after placeCard completes", async () => {
|
||||||
|
// Add a seat element matching the first available role (SC — Shepherd)
|
||||||
|
const seat = document.createElement("div");
|
||||||
|
seat.className = "table-seat";
|
||||||
|
seat.dataset.role = "SC";
|
||||||
|
seat.innerHTML = '<i class="position-status-icon fa-solid fa-ban"></i>';
|
||||||
|
testDiv.appendChild(seat);
|
||||||
|
|
||||||
|
guardConfirm();
|
||||||
|
await Promise.resolve(); // fetch resolves + placeCard fires
|
||||||
|
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
|
||||||
|
|
||||||
|
expect(seat.classList.contains("role-confirmed")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// WS turn_changed pause during animation //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
describe("WS turn_changed pause during placeCard animation", () => {
|
||||||
|
let stack, guardConfirm;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Six table seats, slot 1 starts active
|
||||||
|
for (let i = 1; i <= 6; i++) {
|
||||||
|
const seat = document.createElement("div");
|
||||||
|
seat.className = "table-seat" + (i === 1 ? " active" : "");
|
||||||
|
seat.dataset.slot = String(i);
|
||||||
|
testDiv.appendChild(seat);
|
||||||
|
}
|
||||||
|
stack = document.createElement("div");
|
||||||
|
stack.className = "card-stack";
|
||||||
|
stack.dataset.state = "ineligible";
|
||||||
|
stack.dataset.userSlots = "1";
|
||||||
|
stack.dataset.starterRoles = "";
|
||||||
|
testDiv.appendChild(stack);
|
||||||
|
|
||||||
|
const grid = document.createElement("div");
|
||||||
|
grid.id = "id_tray_grid";
|
||||||
|
testDiv.appendChild(grid);
|
||||||
|
|
||||||
|
// placeCard spy that holds the onComplete callback
|
||||||
|
let heldCallback = null;
|
||||||
|
spyOn(Tray, "placeCard").and.callFake((roleCode, cb) => {
|
||||||
|
heldCallback = cb; // don't call immediately — simulate animation in-flight
|
||||||
|
});
|
||||||
|
spyOn(Tray, "forceClose");
|
||||||
|
|
||||||
|
// Expose heldCallback so tests can fire it
|
||||||
|
Tray._testFirePlaceCardComplete = () => {
|
||||||
|
if (heldCallback) heldCallback();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.showGuard = jasmine.createSpy("showGuard").and.callFake(
|
||||||
|
(anchor, message, onConfirm) => { guardConfirm = onConfirm; }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete Tray._testFirePlaceCardComplete;
|
||||||
|
RoleSelect._testReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turn_changed during animation does not call Tray.forceClose immediately", async () => {
|
||||||
|
RoleSelect.openFan();
|
||||||
|
document.querySelector("#id_role_select .card").click();
|
||||||
|
guardConfirm();
|
||||||
|
await Promise.resolve(); // fetch resolves; placeCard called; animation pending
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: [] }
|
||||||
|
}));
|
||||||
|
expect(Tray.forceClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turn_changed during animation does not immediately move the active seat", async () => {
|
||||||
|
RoleSelect.openFan();
|
||||||
|
document.querySelector("#id_role_select .card").click();
|
||||||
|
guardConfirm();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: [] }
|
||||||
|
}));
|
||||||
|
const activeSeat = testDiv.querySelector(".table-seat.active");
|
||||||
|
expect(activeSeat && activeSeat.dataset.slot).toBe("1"); // still slot 1
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deferred turn_changed is processed when animation completes", async () => {
|
||||||
|
RoleSelect.openFan();
|
||||||
|
document.querySelector("#id_role_select .card").click();
|
||||||
|
guardConfirm();
|
||||||
|
await Promise.resolve(); // flush fetch .then()
|
||||||
|
await new Promise(r => setTimeout(r, 0)); // flush _placeCardDelay → placeCard called, heldCallback set
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: [] }
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Fire onComplete — post-tray delay (0 in tests) still uses setTimeout
|
||||||
|
Tray._testFirePlaceCardComplete();
|
||||||
|
await new Promise(r => setTimeout(r, 0)); // flush _postTrayDelay setTimeout
|
||||||
|
|
||||||
|
// Seat glow is JS-only (tray animation window); after deferred
|
||||||
|
// handleTurnChanged runs, all seat glows are cleared.
|
||||||
|
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turn_changed after animation completes is processed immediately", () => {
|
||||||
|
// No animation in flight — turn_changed should run right away
|
||||||
|
window.dispatchEvent(new CustomEvent("room:turn_changed", {
|
||||||
|
detail: { active_slot: 2, starter_roles: [] }
|
||||||
|
}));
|
||||||
|
expect(Tray.forceClose).toHaveBeenCalled();
|
||||||
|
// Seats are not persistently glowed; all active cleared
|
||||||
|
expect(testDiv.querySelector(".table-seat.active")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// click-guard integration //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
// NOTE: cascade prevention (outside-click on backdrop not closing the //
|
||||||
|
// fan while the guard is active) relies on the guard portal's capture- //
|
||||||
|
// phase stopPropagation, which lives in base.html and requires //
|
||||||
|
// integration testing. The callback contract is fully covered below. //
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
describe("click-guard integration", () => {
|
||||||
|
let guardAnchor, guardMessage, guardConfirm, guardDismiss;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.showGuard = jasmine.createSpy("showGuard").and.callFake(
|
||||||
|
(anchor, message, onConfirm, onDismiss) => {
|
||||||
|
guardAnchor = anchor;
|
||||||
|
guardMessage = message;
|
||||||
|
guardConfirm = onConfirm;
|
||||||
|
guardDismiss = onDismiss;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
RoleSelect.openFan();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clicking a card", () => {
|
||||||
|
let card;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
card = document.querySelector("#id_role_select .card");
|
||||||
|
card.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls window.showGuard", () => {
|
||||||
|
expect(window.showGuard).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the card element as the anchor", () => {
|
||||||
|
expect(guardAnchor).toBe(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("message contains the role name", () => {
|
||||||
|
const roleName = card.querySelector(".card-role-name").textContent.trim();
|
||||||
|
expect(guardMessage).toContain(roleName);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("message contains the role code", () => {
|
||||||
|
expect(guardMessage).toContain(card.dataset.role);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("message contains a <br>", () => {
|
||||||
|
expect(guardMessage).toContain("<br>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not immediately close the fan", () => {
|
||||||
|
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not immediately POST to the select_role URL", () => {
|
||||||
|
expect(window.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds .flipped to the card", () => {
|
||||||
|
expect(card.classList.contains("flipped")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds .guard-active to the card", () => {
|
||||||
|
expect(card.classList.contains("guard-active")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mouseleave does not remove .flipped while guard is active", () => {
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseleave"));
|
||||||
|
expect(card.classList.contains("flipped")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("confirming the guard (OK)", () => {
|
||||||
|
let card;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
card = document.querySelector("#id_role_select .card");
|
||||||
|
card.click();
|
||||||
|
guardConfirm();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .guard-active from the card", () => {
|
||||||
|
expect(card.classList.contains("guard-active")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the fan", () => {
|
||||||
|
expect(document.querySelector(".role-select-backdrop")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POSTs to the select_role URL", () => {
|
||||||
|
expect(window.fetch).toHaveBeenCalledWith(
|
||||||
|
"/epic/room/test-uuid/select-role",
|
||||||
|
jasmine.objectContaining({ method: "POST" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dismissing the guard (NVM or outside click)", () => {
|
||||||
|
let card;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
card = document.querySelector("#id_role_select .card");
|
||||||
|
card.click();
|
||||||
|
guardDismiss();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .guard-active from the card", () => {
|
||||||
|
expect(card.classList.contains("guard-active")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .flipped from the card", () => {
|
||||||
|
expect(card.classList.contains("flipped")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the fan open", () => {
|
||||||
|
expect(document.querySelector(".role-select-backdrop")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not POST to the select_role URL", () => {
|
||||||
|
expect(window.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores normal mouseleave behaviour on the card", () => {
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter"));
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseleave"));
|
||||||
|
expect(card.classList.contains("flipped")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
608
src/static/tests/SigSelectSpec.js
Normal file
608
src/static/tests/SigSelectSpec.js
Normal file
@@ -0,0 +1,608 @@
|
|||||||
|
describe("SigSelect", () => {
|
||||||
|
let testDiv, stageCard, card, statBlock;
|
||||||
|
|
||||||
|
function makeFixture({ reservations = '{}', cardCautions = '[]', polarity = 'levity', userRole = 'PC' } = {}) {
|
||||||
|
testDiv = document.createElement("div");
|
||||||
|
testDiv.innerHTML = `
|
||||||
|
<div class="sig-overlay"
|
||||||
|
data-polarity="${polarity}"
|
||||||
|
data-user-role="${userRole}"
|
||||||
|
data-reserve-url="/epic/room/test/sig-reserve"
|
||||||
|
data-reservations="${reservations.replace(/"/g, '"')}">
|
||||||
|
<div class="sig-modal">
|
||||||
|
<div class="sig-stage">
|
||||||
|
<div class="sig-stage-card" style="display:none">
|
||||||
|
<span class="fan-corner-rank"></span>
|
||||||
|
<i class="stage-suit-icon"></i>
|
||||||
|
<p class="fan-card-name-group"></p>
|
||||||
|
<p class="sig-qualifier-above"></p>
|
||||||
|
<h3 class="fan-card-name"></h3>
|
||||||
|
<p class="sig-qualifier-below"></p>
|
||||||
|
<p class="fan-card-arcana"></p>
|
||||||
|
<p class="fan-card-correspondence"></p>
|
||||||
|
</div>
|
||||||
|
<div class="sig-stat-block">
|
||||||
|
<button class="btn btn-reverse sig-flip-btn" type="button">FLIP</button>
|
||||||
|
<button class="btn btn-caution sig-caution-btn" type="button">!!</button>
|
||||||
|
<div class="stat-face stat-face--upright">
|
||||||
|
<p class="stat-face-label">Upright</p>
|
||||||
|
<ul class="stat-keywords" id="id_stat_keywords_upright"></ul>
|
||||||
|
</div>
|
||||||
|
<div class="stat-face stat-face--reversed">
|
||||||
|
<p class="stat-face-label">Reversed</p>
|
||||||
|
<ul class="stat-keywords" id="id_stat_keywords_reversed"></ul>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-nav-left sig-caution-prev" type="button">◀</button>
|
||||||
|
<button class="btn btn-nav-right sig-caution-next" type="button">▶</button>
|
||||||
|
<div class="sig-caution-tooltip" id="id_sig_caution">
|
||||||
|
<div class="sig-caution-header">
|
||||||
|
<h4 class="sig-caution-title">Caution!</h4>
|
||||||
|
<span class="sig-caution-type">Rival Interaction</span>
|
||||||
|
</div>
|
||||||
|
<p class="sig-caution-shoptalk">[Shoptalk forthcoming]</p>
|
||||||
|
<p class="sig-caution-effect"></p>
|
||||||
|
<span class="sig-caution-index"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sig-deck-grid">
|
||||||
|
<div class="sig-card"
|
||||||
|
data-card-id="42"
|
||||||
|
data-corner-rank="K"
|
||||||
|
data-suit-icon=""
|
||||||
|
data-name-group="Pentacles"
|
||||||
|
data-name-title="King of Pentacles"
|
||||||
|
data-arcana="Minor Arcana"
|
||||||
|
data-correspondence=""
|
||||||
|
data-keywords-upright="action,impulsiveness,ambition"
|
||||||
|
data-keywords-reversed="no direction,disregard for consequences"
|
||||||
|
data-cautions="${cardCautions.replace(/"/g, '"')}">
|
||||||
|
<div class="fan-card-corner fan-card-corner--tl">
|
||||||
|
<span class="fan-corner-rank">K</span>
|
||||||
|
</div>
|
||||||
|
<div class="sig-card-actions">
|
||||||
|
<button class="sig-ok-btn btn btn-confirm">OK</button>
|
||||||
|
<button class="sig-nvm-btn btn btn-cancel">NVM</button>
|
||||||
|
</div>
|
||||||
|
<div class="sig-card-cursors">
|
||||||
|
<span class="sig-cursor sig-cursor--left"></span>
|
||||||
|
<span class="sig-cursor sig-cursor--mid"></span>
|
||||||
|
<span class="sig-cursor sig-cursor--right"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(testDiv);
|
||||||
|
stageCard = testDiv.querySelector(".sig-stage-card");
|
||||||
|
statBlock = testDiv.querySelector(".sig-stat-block");
|
||||||
|
card = testDiv.querySelector(".sig-card");
|
||||||
|
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
||||||
|
Promise.resolve({ ok: true })
|
||||||
|
);
|
||||||
|
window._roomSocket = { readyState: -1, send: jasmine.createSpy("send") };
|
||||||
|
SigSelect._testInit();
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (testDiv) testDiv.remove();
|
||||||
|
delete window._roomSocket;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Stage reveal on mouseenter ─────────────────────────────────────── //
|
||||||
|
|
||||||
|
describe("stage preview", () => {
|
||||||
|
beforeEach(() => makeFixture());
|
||||||
|
|
||||||
|
it("shows the stage card on mouseenter", () => {
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
expect(stageCard.style.display).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the stage card on mouseleave when not frozen", () => {
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||||
|
expect(stageCard.style.display).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT hide the stage card on mouseleave when frozen (reserved)", () => {
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
SigSelect._setFrozen(true);
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||||
|
expect(stageCard.style.display).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Card focus (click → OK overlay) ───────────────────────────────── //
|
||||||
|
|
||||||
|
describe("card click", () => {
|
||||||
|
beforeEach(() => makeFixture());
|
||||||
|
|
||||||
|
it("adds .sig-focused to the clicked card", () => {
|
||||||
|
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(card.classList.contains("sig-focused")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the stage card after click", () => {
|
||||||
|
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(stageCard.style.display).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not focus a card reserved by another role", () => {
|
||||||
|
card.dataset.reservedBy = "NC";
|
||||||
|
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(card.classList.contains("sig-focused")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Lock after reservation ─────────────────────────────────────────── //
|
||||||
|
|
||||||
|
describe("lock after reservation", () => {
|
||||||
|
beforeEach(() => makeFixture());
|
||||||
|
|
||||||
|
it("does not focus another card while one is reserved", () => {
|
||||||
|
// Simulate a reservation on some other card (not this one)
|
||||||
|
SigSelect._setReservedCardId("99");
|
||||||
|
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(card.classList.contains("sig-focused")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call fetch when OK is clicked while a different card is reserved", () => {
|
||||||
|
SigSelect._setReservedCardId("99");
|
||||||
|
var okBtn = card.querySelector(".sig-ok-btn");
|
||||||
|
okBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(window.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows focus again after reservation is cleared", () => {
|
||||||
|
SigSelect._setReservedCardId("99");
|
||||||
|
SigSelect._setReservedCardId(null);
|
||||||
|
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(card.classList.contains("sig-focused")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── WS release clears NVM in a second browser ─────────────────────── //
|
||||||
|
// Simulates the same gamer having two tabs open: tab B must clear its
|
||||||
|
// .sig-reserved--own when tab A presses NVM (WS release event arrives).
|
||||||
|
// The release payload must carry the card_id so the JS can find the element.
|
||||||
|
|
||||||
|
describe("WS release event (second-browser NVM sync)", () => {
|
||||||
|
beforeEach(() => makeFixture({ reservations: '{"42":"PC"}' }));
|
||||||
|
|
||||||
|
it("removes .sig-reserved and .sig-reserved--own on WS release", () => {
|
||||||
|
// Confirm reservation was applied on init
|
||||||
|
expect(card.classList.contains("sig-reserved--own")).toBe(true);
|
||||||
|
expect(card.classList.contains("sig-reserved")).toBe(true);
|
||||||
|
|
||||||
|
// Tab A presses NVM — tab B receives this WS event with the card_id
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||||
|
detail: { card_id: 42, role: "PC", reserved: false },
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(card.classList.contains("sig-reserved--own")).toBe(false);
|
||||||
|
expect(card.classList.contains("sig-reserved")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unfreezes the stage so other cards can be focused after WS release", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||||
|
detail: { card_id: 42, role: "PC", reserved: false },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Should now be able to click the card body again
|
||||||
|
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(card.classList.contains("sig-focused")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Caution tooltip (!!) ──────────────────────────────────────────── //
|
||||||
|
|
||||||
|
describe("caution tooltip", () => {
|
||||||
|
var cautionTooltip, cautionEffect, cautionPrev, cautionNext, cautionBtn;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
makeFixture();
|
||||||
|
cautionTooltip = testDiv.querySelector(".sig-caution-tooltip");
|
||||||
|
cautionEffect = testDiv.querySelector(".sig-caution-effect");
|
||||||
|
cautionPrev = testDiv.querySelector(".sig-caution-prev");
|
||||||
|
cautionNext = testDiv.querySelector(".sig-caution-next");
|
||||||
|
cautionBtn = testDiv.querySelector(".sig-caution-btn");
|
||||||
|
});
|
||||||
|
|
||||||
|
function hover() {
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCaution() {
|
||||||
|
hover();
|
||||||
|
cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
it("!! click adds .sig-caution-open to the stage", () => {
|
||||||
|
openCaution();
|
||||||
|
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FYI click when btn-disabled does not close caution", () => {
|
||||||
|
openCaution();
|
||||||
|
expect(cautionBtn.classList.contains("btn-disabled")).toBe(true);
|
||||||
|
cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows placeholder text when cautions list is empty", () => {
|
||||||
|
card.dataset.cautions = "[]";
|
||||||
|
openCaution();
|
||||||
|
expect(cautionEffect.innerHTML).toContain("pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders first caution effect HTML including .card-ref spans", () => {
|
||||||
|
card.dataset.cautions = JSON.stringify(['First <span class="card-ref">Card</span> effect.']);
|
||||||
|
openCaution();
|
||||||
|
expect(cautionEffect.querySelector(".card-ref")).not.toBeNull();
|
||||||
|
expect(cautionEffect.querySelector(".card-ref").textContent).toBe("Card");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("with 1 caution both nav arrows are disabled", () => {
|
||||||
|
card.dataset.cautions = JSON.stringify(["Single caution."]);
|
||||||
|
openCaution();
|
||||||
|
expect(cautionPrev.disabled).toBe(true);
|
||||||
|
expect(cautionNext.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("with multiple cautions both nav arrows are always enabled", () => {
|
||||||
|
card.dataset.cautions = JSON.stringify(["C1", "C2", "C3", "C4"]);
|
||||||
|
openCaution();
|
||||||
|
expect(cautionPrev.disabled).toBe(false);
|
||||||
|
expect(cautionNext.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("next click advances to second caution", () => {
|
||||||
|
card.dataset.cautions = JSON.stringify(["First", "Second"]);
|
||||||
|
openCaution();
|
||||||
|
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(cautionEffect.innerHTML).toContain("Second");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("next wraps from last caution back to first", () => {
|
||||||
|
card.dataset.cautions = JSON.stringify(["First", "Last"]);
|
||||||
|
openCaution();
|
||||||
|
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(cautionEffect.innerHTML).toContain("First");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prev click goes back to first caution", () => {
|
||||||
|
card.dataset.cautions = JSON.stringify(["First", "Second"]);
|
||||||
|
openCaution();
|
||||||
|
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
cautionPrev.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(cautionEffect.innerHTML).toContain("First");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prev wraps from first caution to last", () => {
|
||||||
|
card.dataset.cautions = JSON.stringify(["First", "Middle", "Last"]);
|
||||||
|
openCaution();
|
||||||
|
cautionPrev.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(cautionEffect.innerHTML).toContain("Last");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("index label shows n / total when multiple cautions", () => {
|
||||||
|
card.dataset.cautions = JSON.stringify(["C1", "C2", "C3"]);
|
||||||
|
openCaution();
|
||||||
|
expect(testDiv.querySelector(".sig-caution-index").textContent).toBe("1 / 3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("index label is empty when only 1 caution", () => {
|
||||||
|
card.dataset.cautions = JSON.stringify(["Only one."]);
|
||||||
|
openCaution();
|
||||||
|
expect(testDiv.querySelector(".sig-caution-index").textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("card mouseleave closes the caution", () => {
|
||||||
|
openCaution();
|
||||||
|
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true);
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||||
|
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opening again resets to first caution", () => {
|
||||||
|
card.dataset.cautions = JSON.stringify(["First", "Second"]);
|
||||||
|
openCaution();
|
||||||
|
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
// Close and reopen
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||||
|
openCaution();
|
||||||
|
expect(cautionEffect.innerHTML).toContain("First");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opening caution adds .btn-disabled and swaps labels to ×", () => {
|
||||||
|
openCaution();
|
||||||
|
var flipBtn = testDiv.querySelector(".sig-flip-btn");
|
||||||
|
expect(flipBtn.classList.contains("btn-disabled")).toBe(true);
|
||||||
|
expect(cautionBtn.classList.contains("btn-disabled")).toBe(true);
|
||||||
|
expect(flipBtn.textContent).toBe("\u00D7");
|
||||||
|
expect(cautionBtn.textContent).toBe("\u00D7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closing caution removes .btn-disabled and restores original labels", () => {
|
||||||
|
var flipBtn = testDiv.querySelector(".sig-flip-btn");
|
||||||
|
var origFlip = flipBtn.textContent;
|
||||||
|
var origCaution = cautionBtn.textContent;
|
||||||
|
openCaution();
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||||
|
expect(flipBtn.classList.contains("btn-disabled")).toBe(false);
|
||||||
|
expect(cautionBtn.classList.contains("btn-disabled")).toBe(false);
|
||||||
|
expect(flipBtn.textContent).toBe(origFlip);
|
||||||
|
expect(cautionBtn.textContent).toBe(origCaution);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking the tooltip closes caution", () => {
|
||||||
|
openCaution();
|
||||||
|
cautionEffect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FLIP click when caution open (btn-disabled) does nothing", () => {
|
||||||
|
openCaution();
|
||||||
|
var flipBtn = testDiv.querySelector(".sig-flip-btn");
|
||||||
|
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true);
|
||||||
|
expect(statBlock.classList.contains("is-reversed")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Stat block: keyword population and FLIP toggle ────────────────── //
|
||||||
|
|
||||||
|
describe("stat block and FLIP", () => {
|
||||||
|
beforeEach(() => makeFixture());
|
||||||
|
|
||||||
|
it("populates upright keywords when a card is hovered", () => {
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
var items = statBlock.querySelectorAll("#id_stat_keywords_upright li");
|
||||||
|
expect(items.length).toBe(3);
|
||||||
|
expect(items[0].textContent).toBe("action");
|
||||||
|
expect(items[1].textContent).toBe("impulsiveness");
|
||||||
|
expect(items[2].textContent).toBe("ambition");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("populates reversed keywords when a card is hovered", () => {
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
var items = statBlock.querySelectorAll("#id_stat_keywords_reversed li");
|
||||||
|
expect(items.length).toBe(2);
|
||||||
|
expect(items[0].textContent).toBe("no direction");
|
||||||
|
expect(items[1].textContent).toBe("disregard for consequences");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FLIP click adds .is-reversed to the stat block", () => {
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
var flipBtn = statBlock.querySelector(".sig-flip-btn");
|
||||||
|
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(statBlock.classList.contains("is-reversed")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("second FLIP click removes .is-reversed", () => {
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
var flipBtn = statBlock.querySelector(".sig-flip-btn");
|
||||||
|
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||||
|
expect(statBlock.classList.contains("is-reversed")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hovering a new card resets .is-reversed", () => {
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
statBlock.querySelector(".sig-flip-btn").dispatchEvent(
|
||||||
|
new MouseEvent("click", { bubbles: true })
|
||||||
|
);
|
||||||
|
expect(statBlock.classList.contains("is-reversed")).toBe(true);
|
||||||
|
|
||||||
|
// Leave and re-enter (simulates moving to a different card)
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
expect(statBlock.classList.contains("is-reversed")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("card with no keywords yields empty lists", () => {
|
||||||
|
card.dataset.keywordsUpright = "";
|
||||||
|
card.dataset.keywordsReversed = "";
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
expect(statBlock.querySelectorAll("#id_stat_keywords_upright li").length).toBe(0);
|
||||||
|
expect(statBlock.querySelectorAll("#id_stat_keywords_reversed li").length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── WS cursor hover (applyHover) ──────────────────────────────────────── //
|
||||||
|
//
|
||||||
|
// Fixture polarity = levity, userRole = PC.
|
||||||
|
// POLARITY_ROLES: levity → [PC, NC, SC] = [left, mid, right]
|
||||||
|
//
|
||||||
|
// Only tests the JS position mapping — colour is CSS-only.
|
||||||
|
|
||||||
|
describe("WS cursor hover", () => {
|
||||||
|
beforeEach(() => makeFixture());
|
||||||
|
|
||||||
|
it("NC hover activates the --mid cursor", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||||
|
detail: { card_id: 42, role: "NC", active: true },
|
||||||
|
}));
|
||||||
|
expect(card.querySelector(".sig-cursor--mid").classList.contains("active")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SC hover activates the --right cursor", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||||
|
detail: { card_id: 42, role: "SC", active: true },
|
||||||
|
}));
|
||||||
|
expect(card.querySelector(".sig-cursor--right").classList.contains("active")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("own role (PC) hover event is ignored — no cursor activates", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||||
|
detail: { card_id: 42, role: "PC", active: true },
|
||||||
|
}));
|
||||||
|
expect(card.querySelectorAll(".sig-cursor.active").length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hover-off removes .active from the cursor", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||||
|
detail: { card_id: 42, role: "NC", active: true },
|
||||||
|
}));
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||||
|
detail: { card_id: 42, role: "NC", active: false },
|
||||||
|
}));
|
||||||
|
expect(card.querySelector(".sig-cursor--mid").classList.contains("active")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hover on unknown card_id is a no-op", () => {
|
||||||
|
expect(() => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||||
|
detail: { card_id: 9999, role: "NC", active: true },
|
||||||
|
}));
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── WS reservation — data-reserved-by attribute ───────────────────────── //
|
||||||
|
//
|
||||||
|
// applyReservation() sets data-reserved-by so the CSS can glow the card in
|
||||||
|
// the reserving gamer's role colour. These tests assert the attribute, not
|
||||||
|
// the colour (CSS variables aren't resolvable in the SpecRunner context).
|
||||||
|
|
||||||
|
describe("WS reservation sets data-reserved-by", () => {
|
||||||
|
beforeEach(() => makeFixture());
|
||||||
|
|
||||||
|
it("peer reservation sets data-reserved-by to the reserving role", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||||
|
detail: { card_id: 42, role: "NC", reserved: true },
|
||||||
|
}));
|
||||||
|
expect(card.dataset.reservedBy).toBe("NC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("peer reservation also adds .sig-reserved class", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||||
|
detail: { card_id: 42, role: "NC", reserved: true },
|
||||||
|
}));
|
||||||
|
expect(card.classList.contains("sig-reserved")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("release removes data-reserved-by", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||||
|
detail: { card_id: 42, role: "NC", reserved: true },
|
||||||
|
}));
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||||
|
detail: { card_id: 42, role: "NC", reserved: false },
|
||||||
|
}));
|
||||||
|
expect(card.dataset.reservedBy).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("own reservation (PC) sets data-reserved-by AND .sig-reserved--own", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||||
|
detail: { card_id: 42, role: "PC", reserved: true },
|
||||||
|
}));
|
||||||
|
expect(card.dataset.reservedBy).toBe("PC");
|
||||||
|
expect(card.classList.contains("sig-reserved--own")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("peer reservation places a thumbs-up float and removes any hand-pointer float", () => {
|
||||||
|
// First, a hover float exists for NC (mid cursor)
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||||
|
detail: { card_id: 42, role: "NC", active: true },
|
||||||
|
}));
|
||||||
|
expect(document.querySelector('.sig-cursor-float[data-role="NC"]')).not.toBeNull();
|
||||||
|
expect(document.querySelector('.fa-hand-pointer[data-role="NC"]')).not.toBeNull();
|
||||||
|
|
||||||
|
// NC then clicks OK — reservation arrives
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||||
|
detail: { card_id: 42, role: "NC", reserved: true },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Thumbs-up replaces hand-pointer
|
||||||
|
const floatEl = document.querySelector('.sig-cursor-float[data-role="NC"]');
|
||||||
|
expect(floatEl).not.toBeNull();
|
||||||
|
expect(floatEl.classList.contains("fa-thumbs-up")).toBe(true);
|
||||||
|
expect(floatEl.classList.contains("fa-hand-pointer")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("peer release removes the thumbs-up float", () => {
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||||
|
detail: { card_id: 42, role: "NC", reserved: true },
|
||||||
|
}));
|
||||||
|
expect(document.querySelector('.sig-cursor-float--reserved[data-role="NC"]')).not.toBeNull();
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||||
|
detail: { card_id: 42, role: "NC", reserved: false },
|
||||||
|
}));
|
||||||
|
expect(document.querySelector('.sig-cursor-float--reserved[data-role="NC"]')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Polarity theming — stage qualifier text ────────────────────────────── //
|
||||||
|
//
|
||||||
|
// On mouseenter, updateStage() injects "Leavened" or "Graven" into the
|
||||||
|
// sig-qualifier-above (non-major) or sig-qualifier-below (major arcana) slot.
|
||||||
|
// Correspondence field is never populated in sig-select context.
|
||||||
|
|
||||||
|
describe("polarity theming — stage qualifier", () => {
|
||||||
|
it("levity non-major card puts 'Leavened' in qualifier-above, qualifier-below empty", () => {
|
||||||
|
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||||
|
// data-arcana defaults to "Minor Arcana" in fixture → non-major
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Leavened");
|
||||||
|
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("levity major arcana card puts 'Leavened' in qualifier-below, qualifier-above empty", () => {
|
||||||
|
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||||
|
card.dataset.arcana = "Major Arcana";
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
|
||||||
|
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => {
|
||||||
|
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||||
|
card.dataset.arcana = "Major Arcana";
|
||||||
|
card.dataset.nameTitle = "The Schizo";
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
expect(testDiv.querySelector(".fan-card-name").textContent).toBe("The Schizo,");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("non-major arcana title has no trailing comma", () => {
|
||||||
|
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||||
|
// fixture default: Minor Arcana, "King of Pentacles"
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
expect(testDiv.querySelector(".fan-card-name").textContent).toBe("King of Pentacles");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gravity non-major card puts 'Graven' in qualifier-above", () => {
|
||||||
|
makeFixture({ polarity: 'gravity', userRole: 'BC' });
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Graven");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gravity major arcana card puts 'Graven' in qualifier-below", () => {
|
||||||
|
makeFixture({ polarity: 'gravity', userRole: 'BC' });
|
||||||
|
card.dataset.arcana = "Major Arcana";
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Graven");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hovering clears qualifier slots from the previous card", () => {
|
||||||
|
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
card.dataset.arcana = "Major Arcana";
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
// Now major — above should be empty, below filled
|
||||||
|
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
|
||||||
|
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("correspondence field is never populated", () => {
|
||||||
|
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||||
|
card.dataset.correspondence = "Il Bagatto (Minchiate)";
|
||||||
|
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||||
|
expect(testDiv.querySelector(".fan-card-correspondence").textContent).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
57
src/static/tests/Spec.js
Normal file
57
src/static/tests/Spec.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
console.log("Spec.js is loading");
|
||||||
|
|
||||||
|
describe("GameArray JavaScript", () => {
|
||||||
|
const inputId= "id_text";
|
||||||
|
const errorClass = "invalid-feedback";
|
||||||
|
const inputSelector = `#${inputId}`;
|
||||||
|
const errorSelector = `.${errorClass}`;
|
||||||
|
let testDiv;
|
||||||
|
let textInput;
|
||||||
|
let errorMsg;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
console.log("beforeEach");
|
||||||
|
testDiv = document.createElement("div");
|
||||||
|
testDiv.innerHTML = `
|
||||||
|
<form>
|
||||||
|
<input
|
||||||
|
id="${inputId}"
|
||||||
|
name="text"
|
||||||
|
class="form-control form-control-lg is-invalid"
|
||||||
|
placeholder="Enter a to-do item"
|
||||||
|
value="Value as submitted"
|
||||||
|
aria-describedby="id_text_feedback"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div id="id_text_feedback" class="${errorClass}">An error message</div>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(testDiv);
|
||||||
|
textInput = document.querySelector(inputSelector);
|
||||||
|
errorMsg = document.querySelector(errorSelector);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
testDiv.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have a useful html fixture", () => {
|
||||||
|
console.log("in test 1");
|
||||||
|
expect(errorMsg.checkVisibility()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should hide error message on input", () => {
|
||||||
|
console.log("in test 2");
|
||||||
|
initialize(inputSelector);
|
||||||
|
textInput.dispatchEvent(new InputEvent("input"));
|
||||||
|
|
||||||
|
expect(errorMsg.checkVisibility()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not hide error message before event is fired", () => {
|
||||||
|
console.log("in test 3");
|
||||||
|
initialize(inputSelector);
|
||||||
|
|
||||||
|
expect(errorMsg.checkVisibility()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
src/static/tests/SpecRunner.html
Normal file
40
src/static/tests/SpecRunner.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="author" content="Disco DeDisco">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
|
||||||
|
<!-- Bootstrap CSS -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/css/bootstrap.min.css"/>
|
||||||
|
<link rel="stylesheet" href="lib/jasmine-6.0.1/jasmine.css">
|
||||||
|
|
||||||
|
<title>Jasmine Spec Runner</title>
|
||||||
|
<link rel="stylesheet" href="lib/jasmine.css">
|
||||||
|
|
||||||
|
<!-- Jasmine -->
|
||||||
|
<script src="lib/jasmine-6.0.1/jasmine.js"></script>
|
||||||
|
<script src="lib/jasmine-6.0.1/jasmine-html.js"></script>
|
||||||
|
<script src="lib/jasmine-6.0.1/boot0.js"></script>
|
||||||
|
<!-- spec files -->
|
||||||
|
<script src="Spec.js"></script>
|
||||||
|
<script src="RoleSelectSpec.js"></script>
|
||||||
|
<script src="TraySpec.js"></script>
|
||||||
|
<script src="SigSelectSpec.js"></script>
|
||||||
|
<!-- src files -->
|
||||||
|
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||||
|
<script src="/static/apps/epic/role-select.js"></script>
|
||||||
|
<script src="/static/apps/epic/tray.js"></script>
|
||||||
|
<script src="/static/apps/epic/sig-select.js"></script>
|
||||||
|
<!-- Jasmine env config (optional) -->
|
||||||
|
<script src="lib/jasmine-6.0.1/boot1.js"></script>
|
||||||
|
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
522
src/static/tests/TraySpec.js
Normal file
522
src/static/tests/TraySpec.js
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
// ── TraySpec.js ───────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Unit specs for tray.js — the per-seat, per-room slide-out panel anchored
|
||||||
|
// to the right edge of the viewport.
|
||||||
|
//
|
||||||
|
// DOM contract assumed by the module:
|
||||||
|
// #id_tray_wrap — outermost container; JS sets style.left for positioning
|
||||||
|
// #id_tray_btn — the drawer-handle button
|
||||||
|
// #id_tray — the tray panel (hidden by default)
|
||||||
|
//
|
||||||
|
// Public API under test:
|
||||||
|
// Tray.init() — compute bounds, apply vertical bounds, attach listeners
|
||||||
|
// Tray.open() — reveal tray, animate wrap to minLeft
|
||||||
|
// Tray.close() — hide tray, animate wrap to maxLeft
|
||||||
|
// Tray.isOpen() — state predicate
|
||||||
|
// Tray.reset() — restore initial state (for afterEach)
|
||||||
|
//
|
||||||
|
// Drag model: tray follows pointer in real-time; position persists on release.
|
||||||
|
// Any leftward drag opens the tray.
|
||||||
|
// Drag > 10px suppresses the subsequent click event.
|
||||||
|
//
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("Tray", () => {
|
||||||
|
let btn, tray, wrap;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
wrap = document.createElement("div");
|
||||||
|
wrap.id = "id_tray_wrap";
|
||||||
|
|
||||||
|
btn = document.createElement("button");
|
||||||
|
btn.id = "id_tray_btn";
|
||||||
|
|
||||||
|
tray = document.createElement("div");
|
||||||
|
tray.id = "id_tray";
|
||||||
|
tray.style.display = "none";
|
||||||
|
|
||||||
|
wrap.appendChild(btn);
|
||||||
|
document.body.appendChild(wrap);
|
||||||
|
document.body.appendChild(tray);
|
||||||
|
|
||||||
|
Tray._testSetLandscape(false); // force portrait regardless of window size
|
||||||
|
Tray.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Tray.reset();
|
||||||
|
wrap.remove();
|
||||||
|
tray.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
// open() //
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
describe("open()", () => {
|
||||||
|
it("makes #id_tray visible", () => {
|
||||||
|
Tray.open();
|
||||||
|
expect(tray.style.display).not.toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds .open to #id_tray_btn", () => {
|
||||||
|
Tray.open();
|
||||||
|
expect(btn.classList.contains("open")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets wrap left to minLeft (0)", () => {
|
||||||
|
Tray.open();
|
||||||
|
expect(wrap.style.left).toBe("0px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calling open() twice does not duplicate .open", () => {
|
||||||
|
Tray.open();
|
||||||
|
Tray.open();
|
||||||
|
const openCount = btn.className.split(" ").filter(c => c === "open").length;
|
||||||
|
expect(openCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
// close() //
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
describe("close()", () => {
|
||||||
|
beforeEach(() => Tray.open());
|
||||||
|
|
||||||
|
it("hides #id_tray after slide + snap both complete", () => {
|
||||||
|
Tray.close();
|
||||||
|
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" }));
|
||||||
|
wrap.dispatchEvent(new Event("animationend"));
|
||||||
|
expect(tray.style.display).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds .snap to wrap after slide transition completes", () => {
|
||||||
|
Tray.close();
|
||||||
|
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" }));
|
||||||
|
expect(wrap.classList.contains("snap")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .snap from wrap once animationend fires", () => {
|
||||||
|
Tray.close();
|
||||||
|
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" }));
|
||||||
|
wrap.dispatchEvent(new Event("animationend"));
|
||||||
|
expect(wrap.classList.contains("snap")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .open from #id_tray_btn", () => {
|
||||||
|
Tray.close();
|
||||||
|
expect(btn.classList.contains("open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets wrap left to maxLeft", () => {
|
||||||
|
Tray.close();
|
||||||
|
expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw if already closed", () => {
|
||||||
|
Tray.close();
|
||||||
|
expect(() => Tray.close()).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
// isOpen() //
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
describe("isOpen()", () => {
|
||||||
|
it("returns false by default", () => {
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true after open()", () => {
|
||||||
|
Tray.open();
|
||||||
|
expect(Tray.isOpen()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false after close()", () => {
|
||||||
|
Tray.open();
|
||||||
|
Tray.close();
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
// Click when closed — wobble wrap, do not open //
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
describe("clicking btn when closed", () => {
|
||||||
|
it("adds .wobble to wrap", () => {
|
||||||
|
btn.click();
|
||||||
|
expect(wrap.classList.contains("wobble")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open the tray", () => {
|
||||||
|
btn.click();
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .wobble once animationend fires on wrap", () => {
|
||||||
|
btn.click();
|
||||||
|
wrap.dispatchEvent(new Event("animationend"));
|
||||||
|
expect(wrap.classList.contains("wobble")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
// Click when open — close, no wobble //
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
describe("clicking btn when open", () => {
|
||||||
|
beforeEach(() => Tray.open());
|
||||||
|
|
||||||
|
it("closes the tray", () => {
|
||||||
|
btn.click();
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add .wobble", () => {
|
||||||
|
btn.click();
|
||||||
|
expect(wrap.classList.contains("wobble")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
// Drag interaction — continuous positioning //
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
describe("drag interaction", () => {
|
||||||
|
function simulateDrag(deltaX) {
|
||||||
|
const startX = 800;
|
||||||
|
btn.dispatchEvent(new PointerEvent("pointerdown", { clientX: startX, bubbles: true }));
|
||||||
|
btn.dispatchEvent(new PointerEvent("pointermove", { clientX: startX + deltaX, bubbles: true }));
|
||||||
|
btn.dispatchEvent(new PointerEvent("pointerup", { clientX: startX + deltaX, bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
it("dragging left opens the tray", () => {
|
||||||
|
simulateDrag(-60);
|
||||||
|
expect(Tray.isOpen()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("any leftward drag opens the tray", () => {
|
||||||
|
simulateDrag(-20);
|
||||||
|
expect(Tray.isOpen()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dragging right does not open the tray", () => {
|
||||||
|
simulateDrag(100);
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drag > 10px suppresses the subsequent click", () => {
|
||||||
|
simulateDrag(-60);
|
||||||
|
btn.click(); // should be swallowed — tray stays open
|
||||||
|
expect(Tray.isOpen()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add .wobble during drag", () => {
|
||||||
|
simulateDrag(-60);
|
||||||
|
expect(wrap.classList.contains("wobble")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
// Landscape mode — Y-axis drag, top-positioned wrap //
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
describe("landscape mode", () => {
|
||||||
|
// Re-init in landscape after the portrait init from outer beforeEach.
|
||||||
|
beforeEach(() => {
|
||||||
|
Tray.reset();
|
||||||
|
Tray._testSetLandscape(true);
|
||||||
|
Tray.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
function simulateDragY(deltaY) {
|
||||||
|
const startY = 50;
|
||||||
|
btn.dispatchEvent(new PointerEvent("pointerdown", { clientY: startY, clientX: 0, bubbles: true }));
|
||||||
|
btn.dispatchEvent(new PointerEvent("pointermove", { clientY: startY + deltaY, clientX: 0, bubbles: true }));
|
||||||
|
btn.dispatchEvent(new PointerEvent("pointerup", { clientY: startY + deltaY, clientX: 0, bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── open() in landscape ─────────────────────────────────────────── //
|
||||||
|
|
||||||
|
describe("open()", () => {
|
||||||
|
it("makes #id_tray visible", () => {
|
||||||
|
Tray.open();
|
||||||
|
expect(tray.style.display).not.toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds .open to #id_tray_btn", () => {
|
||||||
|
Tray.open();
|
||||||
|
expect(btn.classList.contains("open")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("positions wrap via style.top, not style.left", () => {
|
||||||
|
Tray.open();
|
||||||
|
expect(wrap.style.top).not.toBe("");
|
||||||
|
expect(wrap.style.left).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── close() in landscape ────────────────────────────────────────── //
|
||||||
|
|
||||||
|
describe("close()", () => {
|
||||||
|
beforeEach(() => Tray.open());
|
||||||
|
|
||||||
|
it("closes the tray (display not toggled in landscape)", () => {
|
||||||
|
Tray.close();
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .open from #id_tray_btn", () => {
|
||||||
|
Tray.close();
|
||||||
|
expect(btn.classList.contains("open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closed top is less than open top (wrap slides up to close)", () => {
|
||||||
|
const openTop = parseInt(wrap.style.top, 10);
|
||||||
|
Tray.close();
|
||||||
|
const closedTop = parseInt(wrap.style.top, 10);
|
||||||
|
expect(closedTop).toBeLessThan(openTop);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds .snap to wrap after top transition completes", () => {
|
||||||
|
Tray.close();
|
||||||
|
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "top" }));
|
||||||
|
expect(wrap.classList.contains("snap")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .snap from wrap once animationend fires", () => {
|
||||||
|
Tray.close();
|
||||||
|
wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "top" }));
|
||||||
|
wrap.dispatchEvent(new Event("animationend"));
|
||||||
|
expect(wrap.classList.contains("snap")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── drag — Y axis ──────────────────────────────────────────────── //
|
||||||
|
|
||||||
|
describe("drag interaction", () => {
|
||||||
|
it("dragging down opens the tray", () => {
|
||||||
|
simulateDragY(100);
|
||||||
|
expect(Tray.isOpen()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dragging up does not open the tray", () => {
|
||||||
|
simulateDragY(-100);
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drag > 10px downward suppresses subsequent click", () => {
|
||||||
|
simulateDragY(100);
|
||||||
|
btn.click(); // should be swallowed — tray stays open
|
||||||
|
expect(Tray.isOpen()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not set style.left (Y axis only)", () => {
|
||||||
|
simulateDragY(100);
|
||||||
|
expect(wrap.style.left).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not add .wobble during drag", () => {
|
||||||
|
simulateDragY(100);
|
||||||
|
expect(wrap.classList.contains("wobble")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── click when closed — wobble, no open ───────────────────────── //
|
||||||
|
|
||||||
|
describe("clicking btn when closed", () => {
|
||||||
|
it("adds .wobble to wrap", () => {
|
||||||
|
btn.click();
|
||||||
|
expect(wrap.classList.contains("wobble")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not open the tray", () => {
|
||||||
|
btn.click();
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── click when open — close ────────────────────────────────────── //
|
||||||
|
|
||||||
|
describe("clicking btn when open", () => {
|
||||||
|
beforeEach(() => Tray.open());
|
||||||
|
|
||||||
|
it("closes the tray", () => {
|
||||||
|
btn.click();
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── init positions wrap at closed (top) ────────────────────────── //
|
||||||
|
|
||||||
|
it("init sets wrap to closed position (top < 0 or = maxTop)", () => {
|
||||||
|
// After landscape init with no real elements, _maxTop = -(wrapH_fallback - handleH_fallback)
|
||||||
|
// which will be negative. Wrap starts off-screen above.
|
||||||
|
const top = parseInt(wrap.style.top, 10);
|
||||||
|
expect(top).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── resize closes landscape tray ─────────────────────────────── //
|
||||||
|
|
||||||
|
describe("resize closes the tray", () => {
|
||||||
|
it("closes when landscape tray is open", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .open from btn on resize", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(btn.classList.contains("open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets wrap to closed top position on resize", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(parseInt(wrap.style.top, 10)).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-open a closed tray on resize", () => {
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
// window resize — portrait //
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
describe("window resize (portrait)", () => {
|
||||||
|
it("closes the tray when open", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .open from btn on resize", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(btn.classList.contains("open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the tray panel on resize", () => {
|
||||||
|
Tray.open();
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(tray.style.display).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets wrap to closed left position on resize", () => {
|
||||||
|
Tray.open();
|
||||||
|
expect(wrap.style.left).toBe("0px");
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-open a closed tray on resize", () => {
|
||||||
|
window.dispatchEvent(new Event("resize"));
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
// placeCard() //
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
//
|
||||||
|
// placeCard(roleCode, onComplete):
|
||||||
|
// 1. Marks the first .tray-cell with .tray-role-card + data-role.
|
||||||
|
// 2. Opens the tray.
|
||||||
|
// 3. Arc-in animates the cell (.arc-in class, animationend fires).
|
||||||
|
// 4. forceClose() — tray closes instantly.
|
||||||
|
// 5. Calls onComplete.
|
||||||
|
//
|
||||||
|
// The grid always has exactly 8 .tray-cell elements (from the template);
|
||||||
|
// no new elements are inserted.
|
||||||
|
//
|
||||||
|
// ---------------------------------------------------------------------- //
|
||||||
|
|
||||||
|
describe("placeCard()", () => {
|
||||||
|
let grid, firstCell;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
grid = document.createElement("div");
|
||||||
|
grid.id = "id_tray_grid";
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const cell = document.createElement("div");
|
||||||
|
cell.className = "tray-cell";
|
||||||
|
grid.appendChild(cell);
|
||||||
|
}
|
||||||
|
document.body.appendChild(grid);
|
||||||
|
// Re-init so _grid is set (reset() in outer afterEach clears it)
|
||||||
|
Tray.init();
|
||||||
|
firstCell = grid.querySelector(".tray-cell");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
grid.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds .tray-role-card to the first .tray-cell", () => {
|
||||||
|
Tray.placeCard("PC", null);
|
||||||
|
expect(firstCell.classList.contains("tray-role-card")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets data-role on the first cell", () => {
|
||||||
|
Tray.placeCard("NC", null);
|
||||||
|
expect(firstCell.dataset.role).toBe("NC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grid cell count stays at 8", () => {
|
||||||
|
Tray.placeCard("PC", null);
|
||||||
|
expect(grid.children.length).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the tray", () => {
|
||||||
|
Tray.placeCard("PC", null);
|
||||||
|
expect(Tray.isOpen()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds .arc-in to the first cell", () => {
|
||||||
|
Tray.placeCard("PC", null);
|
||||||
|
expect(firstCell.classList.contains("arc-in")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes .arc-in and closes after animationend", () => {
|
||||||
|
Tray.placeCard("PC", null);
|
||||||
|
expect(Tray.isOpen()).toBe(true);
|
||||||
|
firstCell.dispatchEvent(new Event("animationend"));
|
||||||
|
expect(firstCell.classList.contains("arc-in")).toBe(false);
|
||||||
|
expect(Tray.isOpen()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onComplete after the tray closes", () => {
|
||||||
|
let called = false;
|
||||||
|
Tray.placeCard("PC", () => { called = true; });
|
||||||
|
firstCell.dispatchEvent(new Event("animationend"));
|
||||||
|
// Simulate the close transition completing (portrait: 'left' property)
|
||||||
|
const te = new Event("transitionend");
|
||||||
|
te.propertyName = "left";
|
||||||
|
wrap.dispatchEvent(te);
|
||||||
|
expect(called).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("landscape: same behaviour — first cell gets role card", () => {
|
||||||
|
Tray._testSetLandscape(true);
|
||||||
|
Tray.init();
|
||||||
|
Tray.placeCard("EC", null);
|
||||||
|
expect(firstCell.classList.contains("tray-role-card")).toBe(true);
|
||||||
|
expect(firstCell.dataset.role).toBe("EC");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reset() removes .tray-role-card and data-role from cells", () => {
|
||||||
|
Tray.placeCard("PC", null);
|
||||||
|
Tray.reset();
|
||||||
|
expect(firstCell.classList.contains("tray-role-card")).toBe(false);
|
||||||
|
expect(firstCell.dataset.role).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
68
src/static/tests/lib/jasmine-6.0.1/boot0.js
Normal file
68
src/static/tests/lib/jasmine-6.0.1/boot0.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/*
|
||||||
|
Copyright (c) 2008-2019 Pivotal Labs
|
||||||
|
Copyright (c) 2008-2026 The Jasmine developers
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of this software and associated documentation files (the
|
||||||
|
"Software"), to deal in the Software without restriction, including
|
||||||
|
without limitation the rights to use, copy, modify, merge, publish,
|
||||||
|
distribute, sublicense, and/or sell copies of the Software, and to
|
||||||
|
permit persons to whom the Software is furnished to do so, subject to
|
||||||
|
the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be
|
||||||
|
included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||||
|
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||||
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
This file starts the process of "booting" Jasmine. It initializes Jasmine,
|
||||||
|
makes its globals available, and creates the env. This file should be loaded
|
||||||
|
after `jasmine.js` and `jasmine_html.js`, but before `boot1.js` or any project
|
||||||
|
source files or spec files are loaded.
|
||||||
|
*/
|
||||||
|
(function() {
|
||||||
|
const jasmineRequire = window.jasmineRequire || require('./jasmine.js');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ## Require & Instantiate
|
||||||
|
*
|
||||||
|
* Require Jasmine's core files. Specifically, this requires and attaches all of Jasmine's code to the `jasmine` reference.
|
||||||
|
*/
|
||||||
|
const jasmine = jasmineRequire.core(jasmineRequire),
|
||||||
|
global = jasmine.getGlobal();
|
||||||
|
global.jasmine = jasmine;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Since this is being run in a browser and the results should populate to an HTML page, require the HTML-specific Jasmine code, injecting the same reference.
|
||||||
|
*/
|
||||||
|
jasmineRequire.html(jasmine);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the Jasmine environment. This is used to run all specs in a project.
|
||||||
|
*/
|
||||||
|
const env = jasmine.getEnv();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ## The Global Interface
|
||||||
|
*
|
||||||
|
* Build up the functions that will be exposed as the Jasmine public interface. A project can customize, rename or alias any of these functions as desired, provided the implementation remains unchanged.
|
||||||
|
*/
|
||||||
|
const jasmineInterface = jasmineRequire.interface(jasmine, env);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add all of the Jasmine global/public interface to the global scope, so a project can use the public interface directly. For example, calling `describe` in specs instead of `jasmine.getEnv().describe`.
|
||||||
|
*/
|
||||||
|
for (const property in jasmineInterface) {
|
||||||
|
global[property] = jasmineInterface[property];
|
||||||
|
}
|
||||||
|
})();
|
||||||
64
src/static/tests/lib/jasmine-6.0.1/boot1.js
Normal file
64
src/static/tests/lib/jasmine-6.0.1/boot1.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
Copyright (c) 2008-2019 Pivotal Labs
|
||||||
|
Copyright (c) 2008-2026 The Jasmine developers
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of this software and associated documentation files (the
|
||||||
|
"Software"), to deal in the Software without restriction, including
|
||||||
|
without limitation the rights to use, copy, modify, merge, publish,
|
||||||
|
distribute, sublicense, and/or sell copies of the Software, and to
|
||||||
|
permit persons to whom the Software is furnished to do so, subject to
|
||||||
|
the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be
|
||||||
|
included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
||||||
|
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
||||||
|
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||||
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
This file finishes 'booting' Jasmine, performing all of the necessary
|
||||||
|
initialization before executing the loaded environment and all of a project's
|
||||||
|
specs. This file should be loaded after `boot0.js` but before any project
|
||||||
|
source files or spec files are loaded. Thus this file can also be used to
|
||||||
|
customize Jasmine for a project.
|
||||||
|
|
||||||
|
If a project is using Jasmine via the standalone distribution, this file can
|
||||||
|
be customized directly. If you only wish to configure the Jasmine env, you
|
||||||
|
can load another file that calls `jasmine.getEnv().configure({...})`
|
||||||
|
after `boot0.js` is loaded and before this file is loaded.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
const env = jasmine.getEnv();
|
||||||
|
const urls = new jasmine.HtmlReporterV2Urls();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures Jasmine based on the current set of query parameters. This
|
||||||
|
* supports all parameters set by the HTML reporter as well as
|
||||||
|
* spec=partialPath, which filters out specs whose paths don't contain the
|
||||||
|
* parameter.
|
||||||
|
*/
|
||||||
|
env.configure(urls.configFromCurrentUrl());
|
||||||
|
|
||||||
|
const currentWindowOnload = window.onload;
|
||||||
|
window.onload = function() {
|
||||||
|
if (currentWindowOnload) {
|
||||||
|
currentWindowOnload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The HTML reporter needs to be set up here so it can access the DOM. Other
|
||||||
|
// reporters can be added at any time before env.execute() is called.
|
||||||
|
const htmlReporter = new jasmine.HtmlReporterV2({ env, urls });
|
||||||
|
env.addReporter(htmlReporter);
|
||||||
|
env.execute();
|
||||||
|
};
|
||||||
|
})();
|
||||||
1863
src/static/tests/lib/jasmine-6.0.1/jasmine-html.js
Normal file
1863
src/static/tests/lib/jasmine-6.0.1/jasmine-html.js
Normal file
File diff suppressed because it is too large
Load Diff
351
src/static/tests/lib/jasmine-6.0.1/jasmine.css
Normal file
351
src/static/tests/lib/jasmine-6.0.1/jasmine.css
Normal file
File diff suppressed because one or more lines are too long
12412
src/static/tests/lib/jasmine-6.0.1/jasmine.js
Normal file
12412
src/static/tests/lib/jasmine-6.0.1/jasmine.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -79,6 +79,7 @@
|
|||||||
|
|
||||||
#id_dash_applet_menu { @extend %applet-menu; }
|
#id_dash_applet_menu { @extend %applet-menu; }
|
||||||
#id_game_applet_menu { @extend %applet-menu; }
|
#id_game_applet_menu { @extend %applet-menu; }
|
||||||
|
#id_game_kit_menu { @extend %applet-menu; }
|
||||||
#id_wallet_applet_menu { @extend %applet-menu; }
|
#id_wallet_applet_menu { @extend %applet-menu; }
|
||||||
#id_room_menu { @extend %applet-menu; }
|
#id_room_menu { @extend %applet-menu; }
|
||||||
#id_billboard_applet_menu { @extend %applet-menu; }
|
#id_billboard_applet_menu { @extend %applet-menu; }
|
||||||
@@ -93,22 +94,24 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 4.2rem;
|
bottom: 4.2rem;
|
||||||
right: 0.5rem;
|
right: 0.5rem;
|
||||||
z-index: 202;
|
z-index: 314;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#id_dash_applet_menu,
|
#id_dash_applet_menu,
|
||||||
#id_game_applet_menu,
|
#id_game_applet_menu,
|
||||||
|
#id_game_kit_menu,
|
||||||
#id_wallet_applet_menu,
|
#id_wallet_applet_menu,
|
||||||
#id_billboard_applet_menu {
|
#id_billboard_applet_menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 6.6rem;
|
bottom: 6.6rem;
|
||||||
right: 1rem;
|
right: 1rem;
|
||||||
z-index: 201;
|
z-index: 312;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In landscape: shift gear btn and applet menus left of the footer right sidebar
|
// In landscape: shift gear btn and applet menus left of the footer right sidebar
|
||||||
@media (orientation: landscape) and (max-width: 1440px) {
|
// XL override below doubles sidebar to 8rem — centre items in the wider column.
|
||||||
|
@media (orientation: landscape) {
|
||||||
$sidebar-w: 4rem;
|
$sidebar-w: 4rem;
|
||||||
|
|
||||||
.gameboard-page,
|
.gameboard-page,
|
||||||
@@ -117,22 +120,42 @@
|
|||||||
.room-page,
|
.room-page,
|
||||||
.billboard-page {
|
.billboard-page {
|
||||||
> .gear-btn {
|
> .gear-btn {
|
||||||
right: calc(#{$sidebar-w} + 0.5rem);
|
right: 1rem;
|
||||||
bottom: 4.2rem; // same gap above kit btn as portrait; no page-specific overrides needed
|
bottom: 3.95rem; // same gap above kit btn as portrait; no page-specific overrides needed
|
||||||
top: auto;
|
top: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#id_dash_applet_menu,
|
#id_dash_applet_menu,
|
||||||
#id_game_applet_menu,
|
#id_game_applet_menu,
|
||||||
|
#id_game_kit_menu,
|
||||||
#id_wallet_applet_menu,
|
#id_wallet_applet_menu,
|
||||||
|
#id_room_menu,
|
||||||
#id_billboard_applet_menu {
|
#id_billboard_applet_menu {
|
||||||
right: calc(#{$sidebar-w} + 1rem);
|
right: 1rem;
|
||||||
bottom: 6.6rem; // same as portrait, just shifted right of footer sidebar
|
bottom: 6.6rem;
|
||||||
top: auto;
|
top: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) and (min-width: 1800px) {
|
||||||
|
// Centre gear btn and menus in the doubled 8rem sidebar (was 0.5rem from right edge)
|
||||||
|
.gameboard-page,
|
||||||
|
.dashboard-page,
|
||||||
|
.wallet-page,
|
||||||
|
.room-page,
|
||||||
|
.billboard-page {
|
||||||
|
> .gear-btn { right: 2.5rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
#id_dash_applet_menu,
|
||||||
|
#id_game_applet_menu,
|
||||||
|
#id_game_kit_menu,
|
||||||
|
#id_wallet_applet_menu,
|
||||||
|
#id_room_menu,
|
||||||
|
#id_billboard_applet_menu { right: 2.5rem; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Applet box visual shell (reusable outside the grid) ────
|
// ── Applet box visual shell (reusable outside the grid) ────
|
||||||
%applet-box {
|
%applet-box {
|
||||||
border:
|
border:
|
||||||
@@ -179,7 +202,15 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
||||||
a { color: rgba(var(--terUser), 1); text-decoration: none; }
|
a {
|
||||||
|
color: rgba(var(--terUser), 1);
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgba(var(--ninUser), 1);
|
||||||
|
text-shadow: 0 0 0.5rem rgba(var(--terUser), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +234,16 @@
|
|||||||
black 99%,
|
black 99%,
|
||||||
transparent 100%
|
transparent 100%
|
||||||
);
|
);
|
||||||
|
margin-left: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
@media (orientation: landscape) and (min-width: 900px) {
|
||||||
|
margin-left: 2rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
@media (orientation: landscape) and (min-width: 1800px) {
|
||||||
|
margin-left: 4rem;
|
||||||
|
margin-top: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
section {
|
section {
|
||||||
@extend %applet-box;
|
@extend %applet-box;
|
||||||
@@ -219,4 +260,4 @@
|
|||||||
#id_game_applets_container { @extend %applets-grid; }
|
#id_game_applets_container { @extend %applets-grid; }
|
||||||
#id_wallet_applets_container { @extend %applets-grid; }
|
#id_wallet_applets_container { @extend %applets-grid; }
|
||||||
#id_billboard_applets_container { @extend %applets-grid; }
|
#id_billboard_applets_container { @extend %applets-grid; }
|
||||||
#id_game_kit_applets_container { @extend %applets-grid; }
|
#id_gk_sections_container { @extend %applets-grid; }
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ body {
|
|||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.container-fluid {
|
.container-fluid {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -41,7 +42,19 @@ body {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
|
|
||||||
> form { flex-shrink: 0; margin-left: auto; }
|
.navbar-user {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
|
||||||
|
.navbar-text { flex: none; } // prevent expansion; BYE abuts the spans
|
||||||
|
> form { flex-shrink: 0; order: -1; } // BYE left of spans
|
||||||
|
}
|
||||||
|
|
||||||
|
> #id_cont_game { flex-shrink: 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-text,
|
.navbar-text,
|
||||||
@@ -68,13 +81,20 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.input-group {
|
.input-group {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
z-index: 50;
|
||||||
|
|
||||||
.form-control {
|
.form-control {
|
||||||
width: auto;
|
width: 24rem;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +137,7 @@ body {
|
|||||||
|
|
||||||
.alert {
|
.alert {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
margin: 0.75rem 0;
|
margin: 0.75rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
border: 0.1rem solid rgba(var(--priYl), 0.5);
|
border: 0.1rem solid rgba(var(--priYl), 0.5);
|
||||||
color: rgba(var(--priYl), 1);
|
color: rgba(var(--priYl), 1);
|
||||||
@@ -147,19 +167,19 @@ body {
|
|||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
color: rgba(var(--secUser), 0.6);
|
color: rgba(var(--secUser), 0.75);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
text-align: justify;
|
text-align: justify;
|
||||||
text-align-last: justify;
|
text-align-last: justify;
|
||||||
text-justify: inter-character;
|
text-justify: inter-character;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
text-shadow:
|
text-shadow:
|
||||||
1px 1px 0 rgba(255, 255, 255, 0.125), // highlight (up-left)
|
// 1px 1px 0 rgba(255, 255, 255, 0.125), // highlight (up-left)
|
||||||
-0.125rem -0.125rem 0 rgba(0, 0, 0, 0.8) // shadow (down-right)
|
var(--title-shadow-offset) var(--title-shadow-offset) 0 rgba(0, 0, 0, 0.8) // shadow (down-right)
|
||||||
;
|
;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
color: rgba(var(--quaUser), 0.6);
|
color: rgba(var(--quaUser), 0.75);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,8 +193,18 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (orientation: landscape) and (max-width: 1440px) {
|
@media (orientation: landscape) and (max-width: 1100px) {
|
||||||
$sidebar-w: 4rem;
|
body .container {
|
||||||
|
.navbar {
|
||||||
|
h1 {
|
||||||
|
font-size: 1rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
$sidebar-w: 5rem;
|
||||||
|
|
||||||
// ── Sidebar layout: navbar ← left, footer → right ────────────────────────────
|
// ── Sidebar layout: navbar ← left, footer → right ────────────────────────────
|
||||||
body {
|
body {
|
||||||
@@ -192,7 +222,7 @@ body {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
border-right: 0.1rem solid rgba(var(--secUser), 0.4);
|
border-right: 0.1rem solid rgba(var(--secUser), 0.4);
|
||||||
background-color: rgba(var(--priUser), 1);
|
background-color: rgba(var(--priUser), 1);
|
||||||
z-index: 300;
|
z-index: 100;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.container-fluid {
|
.container-fluid {
|
||||||
@@ -203,8 +233,17 @@ body {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
|
margin: 0; // reset portrait margin-right: 0.5rem so container fills full sidebar width
|
||||||
|
|
||||||
> form { flex-shrink: 0; order: -1; } // logout above brand
|
> #id_cont_game { flex-shrink: 0; order: -1; } // cont-game above brand
|
||||||
|
|
||||||
|
.navbar-user {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
.navbar-text { margin: 0; } // cancel landscape margin:auto so BYE abuts
|
||||||
|
> form { order: 0; .btn { margin-top: 0; } } // abut spans
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-brand h1 {
|
.navbar-brand h1 {
|
||||||
@@ -219,6 +258,7 @@ body {
|
|||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
order: 1; // brand at bottom
|
order: 1; // brand at bottom
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin-left: 0; // reset portrait margin-left: 1rem
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
@@ -235,25 +275,17 @@ body {
|
|||||||
.navbar-label { opacity: 0.7; }
|
.navbar-label { opacity: 0.7; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
// .btn-primary {
|
||||||
width: 3rem;
|
// width: 4rem;
|
||||||
height: 3rem;
|
// height: 4rem;
|
||||||
font-size: 0.75rem;
|
// font-size: 0.875rem;
|
||||||
border-width: 0.125rem;
|
// border-width: 0.21rem;
|
||||||
// margin-left: 0.75rem;
|
// }
|
||||||
}
|
|
||||||
|
|
||||||
// Login form: pull out of narrow sidebar, centre in the viewport content area
|
// Login form: offset from fixed sidebars in landscape
|
||||||
.input-group {
|
.input-group {
|
||||||
display: flex;
|
|
||||||
position: fixed;
|
|
||||||
left: $sidebar-w;
|
left: $sidebar-w;
|
||||||
right: $sidebar-w;
|
right: $sidebar-w;
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 50;
|
|
||||||
|
|
||||||
.navbar-text {
|
.navbar-text {
|
||||||
writing-mode: horizontal-tb;
|
writing-mode: horizontal-tb;
|
||||||
@@ -266,26 +298,35 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Container: fill centre, compensate for fixed sidebars on both sides
|
// Container: fill center, compensate for fixed sidebars on both sides.
|
||||||
|
// max-width: none overrides the @media (min-width: 1200px) rule above so the
|
||||||
|
// container fills all available space between the two sidebars on wide screens.
|
||||||
body .container {
|
body .container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
margin-left: $sidebar-w;
|
margin-left: $sidebar-w;
|
||||||
margin-right: $sidebar-w;
|
margin-right: $sidebar-w;
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header row: compact in landscape
|
// Header row: h2 rotates into the left gutter (just right of the navbar border).
|
||||||
|
// position:fixed takes h2 out of flow; .row collapses to zero height automatically.
|
||||||
body .container .row {
|
body .container .row {
|
||||||
padding: 0.25rem 0;
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
.col-lg-6 h2 {
|
}
|
||||||
font-size: 1.5rem;
|
body .container .row .col-lg-6 h2 {
|
||||||
margin: 0 0 0.25rem;
|
position: fixed;
|
||||||
letter-spacing: 0.4em;
|
left: 5rem; // $sidebar-w — flush with the navbar right border
|
||||||
text-align: center;
|
top: 50%;
|
||||||
text-align-last: center;
|
transform: translateY(-50%) rotate(180deg);
|
||||||
}
|
writing-mode: vertical-rl;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
letter-spacing: 0.4em;
|
||||||
|
margin: 0;
|
||||||
|
z-index: 85;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer → fixed right sidebar (mirrors navbar approach — explicit right boundary)
|
// Footer → fixed right sidebar (mirrors navbar approach — explicit right boundary)
|
||||||
@@ -302,14 +343,17 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
border-top: none;
|
border-top: none;
|
||||||
border-left: 0.1rem solid rgba(var(--secUser), 0.3);
|
border-left: 0.1rem solid rgba(var(--secUser), 0.3);
|
||||||
|
background-color: rgba(var(--priUser), 1); // opaque: masks tray sliding behind it
|
||||||
padding: 1rem 0;
|
padding: 1rem 0;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
#id_footer_nav {
|
#id_footer_nav {
|
||||||
flex-direction: column-reverse;
|
flex-direction: column-reverse;
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
gap: 3rem;
|
gap: 1.5rem !important;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
@@ -321,13 +365,104 @@ body {
|
|||||||
|
|
||||||
.footer-container {
|
.footer-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0.75rem;
|
top: 0.25rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 0.55rem;
|
line-height: 0.75 !important;
|
||||||
line-height: 1.4;
|
color: rgba(var(--secUser), 1);
|
||||||
color: rgba(var(--secUser), 0.5);
|
|
||||||
|
|
||||||
br { display: block; }
|
br { display: block; }
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) and (min-width: 700px) {
|
||||||
|
body .container .row .col-lg-6 h2 {
|
||||||
|
@media (min-height: 400px) {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
@media (min-height: 500px) {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body #id_footer {
|
||||||
|
#id_footer_nav {
|
||||||
|
gap: 3rem !important;
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-container {
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── XL landscape (≥1800px): double sidebar widths and scale content ────────────
|
||||||
|
@media (orientation: landscape) and (min-width: 1800px) {
|
||||||
|
$sidebar-xl: 8rem;
|
||||||
|
|
||||||
|
body .container .navbar {
|
||||||
|
width: $sidebar-xl;
|
||||||
|
|
||||||
|
.container-fluid {
|
||||||
|
gap: 2rem;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand h1 { font-size: 2.4rem; }
|
||||||
|
.navbar-text { font-size: 0.78rem; } // 0.65rem × 1.2
|
||||||
|
// .btn-primary { width: 4rem; height: 4rem; font-size: 0.875rem; }
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
left: $sidebar-xl;
|
||||||
|
right: $sidebar-xl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body .container {
|
||||||
|
margin-left: $sidebar-xl;
|
||||||
|
margin-right: $sidebar-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// h2 page title: keep vertical rotation; shift left to clear the wider XL navbar.
|
||||||
|
body .container .row .col-lg-6 h2 {
|
||||||
|
left: 8rem; // $sidebar-xl
|
||||||
|
@media (min-height: 800px) {
|
||||||
|
font-size: 4.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body #id_footer {
|
||||||
|
width: $sidebar-xl;
|
||||||
|
|
||||||
|
#id_footer_nav {
|
||||||
|
gap: 8rem !important;
|
||||||
|
a { font-size: 3rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-container {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -340,13 +475,6 @@ body {
|
|||||||
.navbar-brand h1 {
|
.navbar-brand h1 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
width: 3rem;
|
|
||||||
height: 3rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
border-width: 0.125rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.row .col-lg-6 h2 {
|
.row .col-lg-6 h2 {
|
||||||
@@ -363,26 +491,6 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// @media (min-width: 1024px) and (max-height: 700px) {
|
|
||||||
// body .container .navbar {
|
|
||||||
// padding: 0.5rem 0;
|
|
||||||
|
|
||||||
// .navbar-brand h1 {
|
|
||||||
// font-size: 1.4rem;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// #id_footer {
|
|
||||||
// height: 3.5rem;
|
|
||||||
// padding: 0.7rem 1rem;
|
|
||||||
// gap: 0.35rem;
|
|
||||||
|
|
||||||
// #id_footer_nav a {
|
|
||||||
// font-size: 1.2rem;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
#id_footer {
|
#id_footer {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 6rem;
|
height: 6rem;
|
||||||
@@ -433,8 +541,8 @@ body {
|
|||||||
br { display: none; }
|
br { display: none; }
|
||||||
|
|
||||||
small {
|
small {
|
||||||
font-size: 0.7rem;
|
font-size: 0.75rem;
|
||||||
opacity: 0.6;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -471,6 +579,7 @@ body {
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: rgba(var(--secUser), 0.9);
|
color: rgba(var(--secUser), 0.9);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.guard-actions {
|
.guard-actions {
|
||||||
|
|||||||
@@ -131,6 +131,24 @@ body.page-billscroll {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Drama event entries: 90 / 10 column split ─────────────────────────────
|
||||||
|
|
||||||
|
.drama-event {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
|
||||||
|
.drama-event-body {
|
||||||
|
flex: 0 0 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drama-event-time {
|
||||||
|
flex: 0 0 20%;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── My Scrolls list ────────────────────────────────────────────────────────
|
// ── My Scrolls list ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#id_applet_billboard_my_scrolls {
|
#id_applet_billboard_my_scrolls {
|
||||||
|
|||||||
@@ -25,6 +25,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.btn-primary {
|
&.btn-primary {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-width: 0.21rem;
|
||||||
color: rgba(var(--quaUser), 1);
|
color: rgba(var(--quaUser), 1);
|
||||||
border-color: rgba(var(--quaUser), 1);
|
border-color: rgba(var(--quaUser), 1);
|
||||||
background-color: rgba(var(--quiUser), 1);
|
background-color: rgba(var(--quiUser), 1);
|
||||||
@@ -34,37 +38,6 @@
|
|||||||
0.25rem 0.25rem 0.25rem rgba(var(--quiUser), 0.12)
|
0.25rem 0.25rem 0.25rem rgba(var(--quiUser), 0.12)
|
||||||
;
|
;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-shadow:
|
|
||||||
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
|
|
||||||
0 0 1rem rgba(var(--quaUser), 1)
|
|
||||||
;
|
|
||||||
box-shadow:
|
|
||||||
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
|
|
||||||
0 0 0.5rem rgba(var(--quaUser), 0.12)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
border: 0.18rem solid rgba(var(--quaUser), 1);
|
|
||||||
text-shadow:
|
|
||||||
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
|
|
||||||
0 0 0.12rem rgba(var(--quaUser), 1)
|
|
||||||
;
|
|
||||||
box-shadow:
|
|
||||||
-0.1rem -0.1rem 0.12rem rgba(var(--quiUser), 0.25),
|
|
||||||
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
|
|
||||||
0 0 0.5rem rgba(var(--quaUser), 0.12)
|
|
||||||
;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.btn-xl {
|
|
||||||
width: 4rem;
|
|
||||||
height: 4rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
border-width: 0.21rem;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-shadow:
|
text-shadow:
|
||||||
0.2rem 0.2rem 0.2rem rgba(0, 0, 0, 0.25),
|
0.2rem 0.2rem 0.2rem rgba(0, 0, 0, 0.25),
|
||||||
@@ -72,7 +45,7 @@
|
|||||||
;
|
;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0.24rem 0.24rem 0.5rem rgba(0, 0, 0, 0.25),
|
0.24rem 0.24rem 0.5rem rgba(0, 0, 0, 0.25),
|
||||||
0 0 0.5rem rgba(var(--quaUser), 22)
|
0 0 0.5rem rgba(var(--quaUser), 0.22)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +60,14 @@
|
|||||||
-0.2rem -0.2rem 0.24rem rgba(0, 0, 0, 0.25),
|
-0.2rem -0.2rem 0.24rem rgba(0, 0, 0, 0.25),
|
||||||
0 0 0.5rem rgba(var(--quaUser), 0.22)
|
0 0 0.5rem rgba(var(--quaUser), 0.22)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) and (max-width: 1100px) {
|
||||||
|
width: 2.75rem !important;
|
||||||
|
height: 2.75rem !important;
|
||||||
|
font-size: 0.625rem !important;
|
||||||
|
border-width: 0.125rem !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.btn-abandon {
|
&.btn-abandon {
|
||||||
@@ -300,13 +280,20 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) and (min-width: 1800px) {
|
||||||
|
width: 2.4rem; // 2rem × 1.2
|
||||||
|
height: 2.4rem;
|
||||||
|
font-size: 0.75rem; // 0.63rem × 1.2
|
||||||
|
}
|
||||||
|
|
||||||
&.btn-disabled {
|
&.btn-disabled {
|
||||||
cursor: default !important;
|
cursor: default !important;
|
||||||
|
pointer-events: none;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
padding-bottom: 0.1rem;
|
padding-bottom: 0.1rem;
|
||||||
color: rgba(var(--secUser), 0.25);
|
color: rgba(var(--secUser), 0.25) !important;
|
||||||
background-color: rgba(var(--priUser), 1);
|
background-color: rgba(var(--priUser), 1) !important;
|
||||||
border-color: rgba(var(--secUser), 0.25);
|
border-color: rgba(var(--secUser), 0.25) !important;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.5),
|
0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.5),
|
||||||
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||||
@@ -336,4 +323,144 @@
|
|||||||
;
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.btn-nav-left {
|
||||||
|
color: rgba(var(--priFs), 1);
|
||||||
|
border-color: rgba(var(--priFs), 1);
|
||||||
|
background-color: rgba(var(--terFs), 1);
|
||||||
|
box-shadow:
|
||||||
|
0.1rem 0.1rem 0.12rem rgba(var(--terFs), 0.25),
|
||||||
|
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||||
|
0.25rem 0.25rem 0.25rem rgba(var(--terFs), 0.12)
|
||||||
|
;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-shadow:
|
||||||
|
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 1rem rgba(var(--priFs), 1)
|
||||||
|
;
|
||||||
|
box-shadow:
|
||||||
|
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 0.5rem rgba(var(--priFs), 0.12)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
border: 0.18rem solid rgba(var(--priFs), 1);
|
||||||
|
text-shadow:
|
||||||
|
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 0.12rem rgba(var(--priFs), 1)
|
||||||
|
;
|
||||||
|
box-shadow:
|
||||||
|
-0.1rem -0.1rem 0.12rem rgba(var(--terFs), 0.25),
|
||||||
|
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 0.5rem rgba(var(--priFs), 0.12)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-nav-right {
|
||||||
|
color: rgba(var(--priLm), 1);
|
||||||
|
border-color: rgba(var(--priLm), 1);
|
||||||
|
background-color: rgba(var(--terLm), 1);
|
||||||
|
box-shadow:
|
||||||
|
0.1rem 0.1rem 0.12rem rgba(var(--terLm), 0.25),
|
||||||
|
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||||
|
0.25rem 0.25rem 0.25rem rgba(var(--terLm), 0.12)
|
||||||
|
;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-shadow:
|
||||||
|
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 1rem rgba(var(--priLm), 1)
|
||||||
|
;
|
||||||
|
box-shadow:
|
||||||
|
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 0.5rem rgba(var(--priLm), 0.12)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
border: 0.18rem solid rgba(var(--priLm), 1);
|
||||||
|
text-shadow:
|
||||||
|
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 0.12rem rgba(var(--priLm), 1)
|
||||||
|
;
|
||||||
|
box-shadow:
|
||||||
|
-0.1rem -0.1rem 0.12rem rgba(var(--terLm), 0.25),
|
||||||
|
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 0.5rem rgba(var(--priLm), 0.12)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-reverse {
|
||||||
|
color: rgba(var(--priCy), 1);
|
||||||
|
border-color: rgba(var(--priCy), 1);
|
||||||
|
background-color: rgba(var(--terCy), 1);
|
||||||
|
box-shadow:
|
||||||
|
0.1rem 0.1rem 0.12rem rgba(var(--terCy), 0.25),
|
||||||
|
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||||
|
0.25rem 0.25rem 0.25rem rgba(var(--terCy), 0.12)
|
||||||
|
;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-shadow:
|
||||||
|
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 1rem rgba(var(--priCy), 1)
|
||||||
|
;
|
||||||
|
box-shadow:
|
||||||
|
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 0.5rem rgba(var(--priCy), 0.12)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
border: 0.18rem solid rgba(var(--priCy), 1);
|
||||||
|
text-shadow:
|
||||||
|
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 0.12rem rgba(var(--priCy), 1)
|
||||||
|
;
|
||||||
|
box-shadow:
|
||||||
|
-0.1rem -0.1rem 0.12rem rgba(var(--terCy), 0.25),
|
||||||
|
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 0.5rem rgba(var(--priCy), 0.12)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-tip {
|
||||||
|
color: rgba(var(--priLm), 1);
|
||||||
|
border-color: rgba(var(--priLm), 1);
|
||||||
|
background-color: rgba(var(--terLm), 1);
|
||||||
|
box-shadow:
|
||||||
|
0.1rem 0.1rem 0.12rem rgba(var(--terLm), 0.25),
|
||||||
|
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||||
|
0.25rem 0.25rem 0.25rem rgba(var(--terLm), 0.12)
|
||||||
|
;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-shadow:
|
||||||
|
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 1rem rgba(var(--priLm), 1)
|
||||||
|
;
|
||||||
|
box-shadow:
|
||||||
|
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 0.5rem rgba(var(--priLm), 0.12)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
border: 0.18rem solid rgba(var(--priLm), 1);
|
||||||
|
text-shadow:
|
||||||
|
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 0.12rem rgba(var(--priLm), 1)
|
||||||
|
;
|
||||||
|
box-shadow:
|
||||||
|
-0.1rem -0.1rem 0.12rem rgba(var(--terLm), 0.25),
|
||||||
|
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
|
||||||
|
0 0 0.5rem rgba(var(--priLm), 0.12)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
672
src/static_src/scss/_card-deck.scss
Normal file
672
src/static_src/scss/_card-deck.scss
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
// ─── Card deck primitives — fan cards + sig-select overlay ─────────────────────
|
||||||
|
//
|
||||||
|
// Shared card display classes (.fan-card, .fan-card-corner, .fan-card-face, .fan-nav)
|
||||||
|
// extracted from _game-kit.scss; sig-select overlay extracted from _room.scss.
|
||||||
|
|
||||||
|
// ── Tarot fan modal ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#id_tarot_fan_dialog {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-width: none;
|
||||||
|
max-height: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: rgba(0, 0, 0, 0.88);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::backdrop { display: none; } // Dialog IS the backdrop
|
||||||
|
}
|
||||||
|
|
||||||
|
.tarot-fan-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
perspective: 900px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
&:hover, &.active {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tarot-fan {
|
||||||
|
position: relative;
|
||||||
|
width: 220px;
|
||||||
|
height: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fan-card {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 220px;
|
||||||
|
height: 340px;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: rgba(var(--priUser), 1);
|
||||||
|
border: 0.1rem solid rgba(var(--secUser), 0.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
|
||||||
|
&--active {
|
||||||
|
border-color: rgba(var(--secUser), 1);
|
||||||
|
box-shadow: 0 0 2rem rgba(var(--secUser), 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fan-card-corner {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.15rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: rgba(var(--secUser), 0.75);
|
||||||
|
|
||||||
|
&--tl { top: 0.4rem; left: 0.4rem; }
|
||||||
|
&--br { bottom: 0.4rem; right: 0.4rem; transform: rotate(180deg); }
|
||||||
|
|
||||||
|
.fan-corner-rank {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 0.18rem 0;
|
||||||
|
}
|
||||||
|
i { font-size: 1.5rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fan-card-face {
|
||||||
|
padding: 1.25rem;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
.fan-card-number { font-size: 0.65rem; }
|
||||||
|
.fan-card-name-group { font-size: 0.65rem; margin: 0; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(var(--secUser), 1); }
|
||||||
|
.fan-card-name { font-size: 0.95rem; font-weight: bold; margin: 0; color: rgba(var(--terUser), 1); }
|
||||||
|
.fan-card-arcana { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; color: rgba(var(--secUser), 1); }
|
||||||
|
.fan-card-correspondence { font-size: 0.6rem; font-style: italic; color: rgba(var(--secUser), 0.5); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fan-nav {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 20;
|
||||||
|
font-size: 3rem;
|
||||||
|
line-height: 1;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: rgba(var(--secUser), 0.6);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 1rem;
|
||||||
|
transition: color 0.15s;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
&:hover { color: rgba(var(--secUser), 1); }
|
||||||
|
// Suppress browser focus ring on mouse/touch clicks; retain it for keyboard nav
|
||||||
|
&:focus:not(:focus-visible) { outline: none; box-shadow: none; }
|
||||||
|
&--prev { left: 1rem; }
|
||||||
|
&--next { right: 1rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sig Select overlay (SIG_SELECT phase) ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Two overlays (levity / gravity) run in parallel, one per polarity group.
|
||||||
|
// Layout mirrors the gatekeeper: dark Gaussian backdrop + centred modal.
|
||||||
|
// Inside the modal: upper stage (card preview) + lower mini card grid (no scroll).
|
||||||
|
|
||||||
|
html:has(.sig-backdrop) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
z-index: 100;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 120;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-modal {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%; // respects overlay padding-right set by JS
|
||||||
|
max-width: 420px;
|
||||||
|
max-height: 100%; // respects overlay padding-bottom set by JS
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Stage ────────────────────────────────────────────────────────────────────
|
||||||
|
// flex: 1 — fills all space above the card grid; no background (backdrop blur).
|
||||||
|
// Row layout: preview card bottom-left, stat block fills the right.
|
||||||
|
// Card width is set by sizeSigCard() in room.js (smaller of 40% stage width or
|
||||||
|
// 80% stage height × 5/8) via --sig-card-w CSS variable — libsass can't handle
|
||||||
|
// container query units inside min().
|
||||||
|
|
||||||
|
.sig-stage {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-end;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
// Preview card — width driven by JS via --sig-card-w; aspect-ratio derives height.
|
||||||
|
.sig-stage-card {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: var(--sig-card-w, 120px);
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 5 / 8;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: rgba(var(--priUser), 1);
|
||||||
|
border: 0.15rem solid rgba(var(--secUser), 0.6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
padding: 0.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// game-kit sets .fan-card-corner { position: absolute; top/left offsets }
|
||||||
|
// so these just need display/font overrides; the corners land at the card edges.
|
||||||
|
// All font-sizes scale with --sig-card-w (ratio = original-rem × 16 / 120).
|
||||||
|
.fan-card-corner--tl {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1.1;
|
||||||
|
gap: 0.1rem;
|
||||||
|
|
||||||
|
.fan-corner-rank { font-size: calc(var(--sig-card-w, 120px) * 0.133); font-weight: 700; }
|
||||||
|
i { font-size: calc(var(--sig-card-w, 120px) * 0.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fan-card-corner--br {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 1.1;
|
||||||
|
gap: 0.1rem;
|
||||||
|
|
||||||
|
.fan-corner-rank { font-size: calc(var(--sig-card-w, 120px) * 0.12); font-weight: 700; }
|
||||||
|
i { font-size: calc(var(--sig-card-w, 120px) * 0.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fan-card-face {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.25rem 0.15rem;
|
||||||
|
gap: 0.2rem;
|
||||||
|
|
||||||
|
.fan-card-name-group { font-size: calc(var(--sig-card-w, 120px) * 0.073); opacity: 0.6; }
|
||||||
|
.sig-qualifier-above,
|
||||||
|
.sig-qualifier-below { font-size: calc(var(--sig-card-w, 120px) * 0.093); font-weight: 600; }
|
||||||
|
.fan-card-name { font-size: calc(var(--sig-card-w, 120px) * 0.093); font-weight: 600; }
|
||||||
|
.fan-card-arcana { font-size: calc(var(--sig-card-w, 120px) * 0.067); text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.5; }
|
||||||
|
.fan-card-correspondence{ display: none; } // Minchiate equivalence shown in game-kit only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat block — same dimensions as the preview card (width × 5:8 aspect).
|
||||||
|
// flex: 0 0 auto so it doesn't stretch to fill the stage; the rest of the
|
||||||
|
// stage row is simply empty, giving the card room to breathe.
|
||||||
|
.sig-stat-block {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: var(--sig-card-w, 120px);
|
||||||
|
height: calc(var(--sig-card-w, 120px) * 8 / 5);
|
||||||
|
align-self: flex-end;
|
||||||
|
background: rgba(var(--priUser), 0.5);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
border: 0.1rem solid rgba(var(--terUser), 0.15);
|
||||||
|
display: none;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
|
||||||
|
.sig-flip-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: -1rem;
|
||||||
|
right: -1rem;
|
||||||
|
margin: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-caution-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 1.25rem;
|
||||||
|
right: -1rem;
|
||||||
|
margin: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caution tooltip — covers the entire stat block (inset: 0), z-index above buttons.
|
||||||
|
.sig-caution-tooltip {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60;
|
||||||
|
background-color: rgba(var(--tooltip-bg), 0.6);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
border: 0.1rem solid rgba(var(--priYl), 0.35);
|
||||||
|
padding: 0.75rem;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-caution-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-caution-title {
|
||||||
|
font-size: calc(var(--sig-card-w, 120px) * 0.093);
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
color: rgba(var(--priYl), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-caution-type {
|
||||||
|
font-size: calc(var(--sig-card-w, 120px) * 0.058);
|
||||||
|
opacity: 0.7;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-caution-shoptalk {
|
||||||
|
font-size: calc(var(--sig-card-w, 120px) * 0.063);
|
||||||
|
opacity: 0.55;
|
||||||
|
margin: 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-caution-effect {
|
||||||
|
flex: 1;
|
||||||
|
font-size: calc(var(--sig-card-w, 120px) * 0.075);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.55;
|
||||||
|
|
||||||
|
.card-ref {
|
||||||
|
color: rgba(var(--terUser), 1);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-caution-index {
|
||||||
|
font-size: calc(var(--sig-card-w, 120px) * 0.063);
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nav arrows portaled out of tooltip — sit at bottom corners above tooltip (z-70)
|
||||||
|
.sig-caution-prev,
|
||||||
|
.sig-caution-next {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1rem;
|
||||||
|
margin: 0;
|
||||||
|
z-index: 70;
|
||||||
|
}
|
||||||
|
.sig-caution-prev { left: -1rem; }
|
||||||
|
.sig-caution-next { right: -1rem; }
|
||||||
|
|
||||||
|
.stat-face {
|
||||||
|
display: none;
|
||||||
|
padding: calc(var(--sig-card-w, 120px) * 0.37) calc(var(--sig-card-w, 120px) * 0.1) calc(var(--sig-card-w, 120px) * 0.08);
|
||||||
|
|
||||||
|
&--upright { display: block; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-reversed {
|
||||||
|
.stat-face--upright { display: none; }
|
||||||
|
.stat-face--reversed { display: block; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-face-label {
|
||||||
|
font-size: calc(var(--sig-card-w, 120px) * 0.063);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
opacity: 0.4;
|
||||||
|
margin: 0 0 calc(var(--sig-card-w, 120px) * 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-keywords {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
font-size: calc(var(--sig-card-w, 120px) * 0.083);
|
||||||
|
padding: calc(var(--sig-card-w, 120px) * 0.042) 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
border-bottom: 0.05rem solid rgba(var(--terUser), 0.12);
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sig-stage--frozen .sig-stat-block { display: block; }
|
||||||
|
&.sig-caution-open .sig-stat-block {
|
||||||
|
.sig-caution-tooltip { display: flex; }
|
||||||
|
.sig-caution-prev, .sig-caution-next { display: inline-flex; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Mini card grid ───────────────────────────────────────────────────────────
|
||||||
|
// flex: 0 0 auto — shrinks to card content; no background (backdrop blur).
|
||||||
|
// align-content: start prevents CSS grid from distributing extra height between rows.
|
||||||
|
|
||||||
|
.sig-deck-grid {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
align-content: start;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0 1rem 5rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-card {
|
||||||
|
aspect-ratio: 5 / 8;
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
background: rgba(var(--priUser), 0.97);
|
||||||
|
border: 1px solid rgba(var(--secUser), 0.3);
|
||||||
|
position: relative;
|
||||||
|
cursor: grab;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// game-kit sets .fan-card-corner { position:absolute; top:0.4rem; left:0.4rem }
|
||||||
|
// Override: center the element within the card instead.
|
||||||
|
.fan-card-corner--tl {
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
gap: 0; // game-kit has gap:0.15rem — too large at 0.5rem font-size
|
||||||
|
|
||||||
|
.fan-corner-rank { font-size: 1rem; font-weight: 700; }
|
||||||
|
i { font-size: 0.75rem; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// OK / NVM overlay — appears on click (focused) or own reservation
|
||||||
|
.sig-card-actions {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
background: rgba(var(--priUser), 0.92);
|
||||||
|
border-radius: inherit;
|
||||||
|
|
||||||
|
.sig-nvm-btn { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sig-focused .sig-card-actions { display: flex; }
|
||||||
|
&.sig-reserved--own .sig-card-actions {
|
||||||
|
display: flex;
|
||||||
|
.sig-ok-btn { display: none; }
|
||||||
|
.sig-nvm-btn { display: flex; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor strip — hangs below the card bottom edge; overflow: visible allows this.
|
||||||
|
.sig-card-cursors {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -0.6rem;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rise above DOM-order siblings when a peer's cursor is active on this card.
|
||||||
|
// Without this, later cards in the grid paint over the overflowing cursor icons.
|
||||||
|
&:has(.sig-cursor.active) { z-index: 5; }
|
||||||
|
|
||||||
|
&:hover:not([data-reserved-by]) {
|
||||||
|
border-color: rgba(var(--secUser), 0.8);
|
||||||
|
box-shadow: 0 0 4px rgba(var(--secUser), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sig-reserved {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role-coloured reservation glow — border/shadow matches the reserving gamer's role.
|
||||||
|
// data-reserved-by is set by applyReservation() in sig-select.js.
|
||||||
|
// Own reservation also shows role colour (same as peers see), not a separate style.
|
||||||
|
&.sig-reserved {
|
||||||
|
&[data-reserved-by="PC"] { border-color: rgba(var(--priRd), 1); box-shadow: 0 0 0 2px rgba(var(--priRd), 1); }
|
||||||
|
&[data-reserved-by="NC"] { border-color: rgba(var(--priYl), 1); box-shadow: 0 0 0 2px rgba(var(--priYl), 1); }
|
||||||
|
&[data-reserved-by="EC"] { border-color: rgba(var(--priGn), 1); box-shadow: 0 0 0 2px rgba(var(--priGn), 1); }
|
||||||
|
&[data-reserved-by="SC"] { border-color: rgba(var(--priCy), 1); box-shadow: 0 0 0 2px rgba(var(--priCy), 1); }
|
||||||
|
&[data-reserved-by="AC"] { border-color: rgba(var(--priId), 1); box-shadow: 0 0 0 2px rgba(var(--priId), 1); }
|
||||||
|
&[data-reserved-by="BC"] { border-color: rgba(var(--priFs), 1); box-shadow: 0 0 0 2px rgba(var(--priFs), 1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.sig-reserved--own {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Cursor anchors ───────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Three tiny dots along the bottom of each mini card, one per role in the group.
|
||||||
|
// Inactive: invisible. Active (another gamer is hovering): role-coloured dot.
|
||||||
|
// Position order is fixed per polarity (POLARITY_ROLES in sig-select.js):
|
||||||
|
// levity (PC / NC / SC) → left / mid / right
|
||||||
|
// gravity (BC / EC / AC) → left / mid / right
|
||||||
|
|
||||||
|
// In-card cursor elements — invisible anchors only.
|
||||||
|
// Visible icons are portaled to document root by applyHover() in sig-select.js.
|
||||||
|
.sig-cursor {
|
||||||
|
display: block;
|
||||||
|
font-size: 0; // zero-size: no layout impact, just carries .active class
|
||||||
|
color: transparent;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Floating cursor portal ───────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// sig-select.js creates these <i> elements inside #id_sig_cursor_portal, a
|
||||||
|
// position:fixed root-level container, so they escape all overflow/clip contexts.
|
||||||
|
// Positioned via getBoundingClientRect() on the card element.
|
||||||
|
|
||||||
|
#id_sig_cursor_portal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 200; // above sig-overlay (120), below tray (310)
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sig-cursor-float {
|
||||||
|
position: absolute;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
transform: translateX(-50%); // centre on the x coordinate from JS
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role-specific colour + outline shadow + ninUser glow
|
||||||
|
.sig-cursor-float[data-role="PC"] {
|
||||||
|
color: rgba(var(--priRd), 1);
|
||||||
|
text-shadow: 2px 0 0 rgba(var(--priOr),1), -2px 0 0 rgba(var(--priOr),1),
|
||||||
|
0 2px 0 rgba(var(--priOr),1), 0 -2px 0 rgba(var(--priOr),1),
|
||||||
|
0 0 6px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.sig-cursor-float[data-role="NC"] {
|
||||||
|
color: rgba(var(--priYl), 1);
|
||||||
|
text-shadow: 2px 0 0 rgba(var(--priLm),1), -2px 0 0 rgba(var(--priLm),1),
|
||||||
|
0 2px 0 rgba(var(--priLm),1), 0 -2px 0 rgba(var(--priLm),1),
|
||||||
|
0 0 6px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.sig-cursor-float[data-role="EC"] {
|
||||||
|
color: rgba(var(--priGn), 1);
|
||||||
|
text-shadow: 2px 0 0 rgba(var(--priTk),1), -2px 0 0 rgba(var(--priTk),1),
|
||||||
|
0 2px 0 rgba(var(--priTk),1), 0 -2px 0 rgba(var(--priTk),1),
|
||||||
|
0 0 6px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.sig-cursor-float[data-role="SC"] {
|
||||||
|
color: rgba(var(--priCy), 1);
|
||||||
|
text-shadow: 2px 0 0 rgba(var(--priBl),1), -2px 0 0 rgba(var(--priBl),1),
|
||||||
|
0 2px 0 rgba(var(--priBl),1), 0 -2px 0 rgba(var(--priBl),1),
|
||||||
|
0 0 6px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.sig-cursor-float[data-role="AC"] {
|
||||||
|
color: rgba(var(--priId), 1);
|
||||||
|
text-shadow: 2px 0 0 rgba(var(--priVt),1), -2px 0 0 rgba(var(--priVt),1),
|
||||||
|
0 2px 0 rgba(var(--priVt),1), 0 -2px 0 rgba(var(--priVt),1),
|
||||||
|
0 0 6px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.sig-cursor-float[data-role="BC"] {
|
||||||
|
color: rgba(var(--priFs), 1);
|
||||||
|
text-shadow: 2px 0 0 rgba(var(--priMe),1), -2px 0 0 rgba(var(--priMe),1),
|
||||||
|
0 2px 0 rgba(var(--priMe),1), 0 -2px 0 rgba(var(--priMe),1),
|
||||||
|
0 0 6px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Polarity theming — card colour inversion ────────────────────────────────
|
||||||
|
//
|
||||||
|
// Gravity (Graven): --priUser bg / --secUser text — standard dark palette.
|
||||||
|
// Levity (Leavened): --secUser bg / --priUser text — inverted, lighter feel.
|
||||||
|
// Both mini-cards and the stage preview card follow the same rule.
|
||||||
|
|
||||||
|
.sig-overlay[data-polarity="levity"] {
|
||||||
|
// Mini card: inverted palette. game-kit sets explicit colours on .fan-card-name
|
||||||
|
// and .fan-card-corner that out-specifc the parent color, so re-target them here.
|
||||||
|
.sig-card {
|
||||||
|
background: rgba(var(--secUser), 0.97);
|
||||||
|
border-color: rgba(var(--priUser), 0.3);
|
||||||
|
color: rgba(var(--priUser), 1);
|
||||||
|
.fan-card-corner { color: rgba(var(--priUser), 0.75); }
|
||||||
|
.fan-card-name { color: rgba(var(--quiUser), 1); }
|
||||||
|
// OK / NVM overlay — must match the inverted card background
|
||||||
|
.sig-card-actions { background: rgba(var(--secUser), 0.92); }
|
||||||
|
}
|
||||||
|
// Stage preview card: same inversion + title colour.
|
||||||
|
// .fan-card-name-group and .fan-card-arcana have explicit color in the base
|
||||||
|
// .fan-card-face rule (specificity 0,2,0) — must re-target them here (0,3,0).
|
||||||
|
// Opacity dim is still applied by the nested sig-stage-card rule.
|
||||||
|
.sig-stage-card {
|
||||||
|
background: rgba(var(--secUser), 1);
|
||||||
|
border-color: rgba(var(--priUser), 0.6);
|
||||||
|
color: rgba(var(--priUser), 1);
|
||||||
|
.fan-card-corner { color: rgba(var(--priUser), 0.75); }
|
||||||
|
.fan-card-name-group{ color: rgba(var(--priUser), 1); }
|
||||||
|
.fan-card-name { color: rgba(var(--quiUser), 1); }
|
||||||
|
.fan-card-arcana { color: rgba(var(--priUser), 1); }
|
||||||
|
}
|
||||||
|
// Polarity qualifier: same colour as the card title in this context
|
||||||
|
.sig-qualifier-above,
|
||||||
|
.sig-qualifier-below { color: rgba(var(--quiUser), 1); }
|
||||||
|
// card-ref spans inside the caution tooltip — must match the base rule's
|
||||||
|
// .sig-stat-block .sig-caution-effect .card-ref specificity (0,3,0) to win.
|
||||||
|
.sig-caution-effect .card-ref { color: rgba(var(--quiUser), 1); }
|
||||||
|
// Cursor colours live in .sig-cursor-float[data-role] rules (portal elements)
|
||||||
|
}
|
||||||
|
.sig-overlay[data-polarity="gravity"] {
|
||||||
|
// Stat block: invert priUser/secUser so gravity gets the same stark contrast as leavened cards
|
||||||
|
.sig-stat-block {
|
||||||
|
background: rgba(var(--secUser), 0.75);
|
||||||
|
color: rgba(var(--priUser), 1);
|
||||||
|
border-color: rgba(var(--priUser), 0.15);
|
||||||
|
}
|
||||||
|
// Caution tooltip: --tooltip-bg is black so priUser text (dark) would be invisible —
|
||||||
|
// override to secUser (light) so body text reads against the dark backdrop.
|
||||||
|
.sig-caution-tooltip { color: rgba(var(--secUser), 1); }
|
||||||
|
// Polarity qualifier: terUser for gravity (quiUser is levity's equivalent)
|
||||||
|
.sig-qualifier-above,
|
||||||
|
.sig-qualifier-below { color: rgba(var(--terUser), 1); }
|
||||||
|
// Cursor colours live in .sig-cursor-float[data-role] rules (portal elements)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Sig select: landscape overrides ─────────────────────────────────────────
|
||||||
|
// Landscape base: 9×2 grid of 3rem cards. At ≥992px (wide enough for 18 cards
|
||||||
|
// at 3rem + 4rem left + ~4rem right): collapse to a single 18×1 row so the
|
||||||
|
// stage preview gets maximum vertical real-estate.
|
||||||
|
// padding-left clears the fixed left navbar (JS sets right/bottom but not left).
|
||||||
|
// Grid margins reset to 0 — overlay padding handles all edge clearance.
|
||||||
|
|
||||||
|
@media (orientation: landscape) {
|
||||||
|
.sig-modal {
|
||||||
|
max-width: none;
|
||||||
|
flex-direction: row; // grid to the right, stage + card preview to the left
|
||||||
|
margin-left: 4rem;
|
||||||
|
margin-right: 3rem;
|
||||||
|
}
|
||||||
|
.sig-stage {
|
||||||
|
min-width: 0; // allow shrinking in row layout; align-items:flex-end already set
|
||||||
|
}
|
||||||
|
.sig-deck-grid {
|
||||||
|
grid-template-columns: repeat(6, 2.5rem);
|
||||||
|
margin: 0;
|
||||||
|
align-self: flex-end; // sit at the bottom of the modal row
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) and (min-width: 900px) {
|
||||||
|
// Wide landscape: revert to stacked layout (stage top, 18-card row grid bottom).
|
||||||
|
.sig-modal {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.sig-stage {
|
||||||
|
min-width: auto;
|
||||||
|
align-self: stretch; // fill full modal width so JS sizeSigCard() gets correct stageWidth
|
||||||
|
margin-left: 3rem;
|
||||||
|
}
|
||||||
|
.sig-deck-grid {
|
||||||
|
grid-template-columns: repeat(18, 3rem);
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (orientation: landscape) and (min-width: 1800px) {
|
||||||
|
// Sig overlay: clear doubled sidebars (8rem each instead of 4rem/6rem)
|
||||||
|
.sig-overlay { padding-left: 8rem; padding-right: 8rem; }
|
||||||
|
.sig-stage {
|
||||||
|
align-self: stretch; // fill full modal width so JS sizeSigCard() gets correct stageWidth
|
||||||
|
margin-left: 3rem;
|
||||||
|
}
|
||||||
|
.sig-deck-grid {
|
||||||
|
grid-template-columns: repeat(18, 5rem);
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Room menu: base right: 0.5rem (same-specificity ID rule) overrides _applets.scss
|
||||||
|
// XL block because _card-deck.scss is imported after _applets.scss. Re-declare here to win the cascade.
|
||||||
|
#id_room_menu { right: 2.5rem; }
|
||||||
|
}
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ body.page-dashboard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (orientation: landscape) and (max-width: 1440px) {
|
@media (orientation: landscape) {
|
||||||
// Reset the 666px min-width so #id_dash_content shrinks to fit within the
|
// Reset the 666px min-width so #id_dash_content shrinks to fit within the
|
||||||
// sidebar-bounded container rather than overflowing into the footer sidebar.
|
// sidebar-bounded container rather than overflowing into the footer sidebar.
|
||||||
#id_dash_content {
|
#id_dash_content {
|
||||||
|
|||||||
@@ -3,13 +3,17 @@
|
|||||||
bottom: 0.5rem;
|
bottom: 0.5rem;
|
||||||
right: 0.5rem;
|
right: 0.5rem;
|
||||||
|
|
||||||
@media (orientation: landscape) and (max-width: 1440px) {
|
@media (orientation: landscape) {
|
||||||
right: calc(4rem + 0.5rem);
|
right: 1rem;
|
||||||
bottom: 0.75rem;
|
bottom: 0.5rem;
|
||||||
top: auto;
|
top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
z-index: 305;
|
@media (orientation: landscape) and (min-width: 1800px) {
|
||||||
|
right: 2.5rem; // centre in doubled 8rem sidebar
|
||||||
|
}
|
||||||
|
|
||||||
|
z-index: 318;
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: rgba(var(--secUser), 1);
|
color: rgba(var(--secUser), 1);
|
||||||
@@ -40,16 +44,16 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-top: 0.1rem solid rgba(var(--terUser), 0.3);
|
border-top: 0.1rem solid rgba(var(--quaUser), 1);
|
||||||
background: rgba(var(--priUser), 0.97);
|
background: rgba(var(--priUser), 0.97);
|
||||||
z-index: 204;
|
z-index: 316;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
@media (orientation: landscape) and (max-width: 1440px) {
|
@media (orientation: landscape) {
|
||||||
$sidebar-w: 4rem;
|
$sidebar-w: 4rem;
|
||||||
// left: $sidebar-w;
|
// left: $sidebar-w;
|
||||||
right: $sidebar-w;
|
right: $sidebar-w;
|
||||||
z-index: 301;
|
z-index: 316;
|
||||||
}
|
}
|
||||||
// Closed state
|
// Closed state
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
@@ -81,7 +85,7 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
color: rgba(var(--secUser), 0.35);
|
color: rgba(var(--quaUser), 0.75);
|
||||||
writing-mode: vertical-rl;
|
writing-mode: vertical-rl;
|
||||||
text-orientation: mixed;
|
text-orientation: mixed;
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
@@ -112,12 +116,13 @@
|
|||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 0.125rem;
|
padding: 0 0.125rem;
|
||||||
|
color: rgba(var(--terUser), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.kit-bag-placeholder {
|
.kit-bag-placeholder {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
opacity: 0.3;
|
|
||||||
padding: 0 0.125rem;
|
padding: 0 0.125rem;
|
||||||
|
color: rgba(var(--quaUser), 0.3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +146,13 @@
|
|||||||
|
|
||||||
// ── Game Kit page ────────────────────────────────────────────────────────────
|
// ── Game Kit page ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#id_game_kit_applets_container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
#id_game_kit_applets_container section {
|
#id_game_kit_applets_container section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -200,108 +212,3 @@
|
|||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tarot fan modal ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
#id_tarot_fan_dialog {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
max-width: none;
|
|
||||||
max-height: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: rgba(0, 0, 0, 0.88);
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&::backdrop { display: none; } // Dialog IS the backdrop
|
|
||||||
}
|
|
||||||
|
|
||||||
.tarot-fan-wrap {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
perspective: 900px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tarot-fan {
|
|
||||||
position: relative;
|
|
||||||
width: 220px;
|
|
||||||
height: 340px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fan-card {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
width: 220px;
|
|
||||||
height: 340px;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
background: rgba(var(--priUser), 1);
|
|
||||||
border: 0.1rem solid rgba(var(--secUser), 0.4);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: transform 0.25s ease, opacity 0.25s ease;
|
|
||||||
transform-style: preserve-3d;
|
|
||||||
|
|
||||||
&--active {
|
|
||||||
border-color: rgba(var(--secUser), 1);
|
|
||||||
box-shadow: 0 0 2rem rgba(var(--secUser), 0.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.fan-card-corner {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.15rem;
|
|
||||||
line-height: 1;
|
|
||||||
color: rgba(var(--secUser), 0.75);
|
|
||||||
|
|
||||||
&--tl { top: 0.4rem; left: 0.4rem; }
|
|
||||||
&--br { bottom: 0.4rem; right: 0.4rem; transform: rotate(180deg); }
|
|
||||||
|
|
||||||
.fan-corner-rank {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: bold;
|
|
||||||
padding: 0.18rem 0;
|
|
||||||
}
|
|
||||||
i { font-size: 1.5rem; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.fan-card-face {
|
|
||||||
padding: 1.25rem;
|
|
||||||
text-align: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
|
|
||||||
.fan-card-number { font-size: 0.65rem; opacity: 0.5; }
|
|
||||||
.fan-card-name-group { font-size: 0.65rem; opacity: 0.5; margin: 0; text-transform: uppercase; letter-spacing: 0.08em; }
|
|
||||||
.fan-card-name { font-size: 0.95rem; font-weight: bold; margin: 0; }
|
|
||||||
.fan-card-arcana { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; opacity: 0.6; }
|
|
||||||
.fan-card-correspondence { font-size: 0.6rem; opacity: 0.45; font-style: italic; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.fan-nav {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 20;
|
|
||||||
font-size: 3rem;
|
|
||||||
line-height: 1;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
color: rgba(var(--secUser), 0.6);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 1rem;
|
|
||||||
transition: color 0.15s;
|
|
||||||
pointer-events: auto;
|
|
||||||
|
|
||||||
&:hover { color: rgba(var(--secUser), 1); }
|
|
||||||
&--prev { left: 1rem; }
|
|
||||||
&--next { right: 1rem; }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ body.page-gameboard {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (orientation: landscape) and (max-width: 1440px) {
|
@media (orientation: landscape) {
|
||||||
// Restore clip in landscape — overrides the >738px overflow:visible above,
|
// Restore clip in landscape — overrides the >738px overflow:visible above,
|
||||||
// preventing the gameboard applets from bleeding into the footer sidebar.
|
// preventing the gameboard applets from bleeding into the footer sidebar.
|
||||||
body.page-gameboard .container {
|
body.page-gameboard .container {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user