Compare commits
97 Commits
b3bc422f46
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71ef3dcb7f | ||
|
|
9beb21bffe | ||
|
|
6248d95bf3 | ||
|
|
44cf399352 | ||
|
|
df2b353ebd | ||
|
|
3fd1f5e990 | ||
|
|
02a7a0ef2e | ||
|
|
cc2ab869f1 | ||
|
|
8c711ac674 | ||
|
|
b8af0041cc | ||
|
|
97ec2f6ee6 | ||
|
|
0a135c2149 | ||
|
|
f1e9a9657b | ||
|
|
32d8d97360 | ||
|
|
df421fb6c0 | ||
|
|
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 |
@@ -22,6 +22,33 @@ steps:
|
|||||||
- python manage.py test apps
|
- python manage.py test apps
|
||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
|
path:
|
||||||
|
- "src/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- ".woodpecker/main.yaml"
|
||||||
|
|
||||||
|
- 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
|
||||||
|
path:
|
||||||
|
- "src/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- ".woodpecker/main.yaml"
|
||||||
|
|
||||||
- name: test-FTs
|
- name: test-FTs
|
||||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||||
@@ -37,10 +64,13 @@ 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
|
||||||
|
path:
|
||||||
|
- "src/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- ".woodpecker/main.yaml"
|
||||||
|
|
||||||
- name: screendumps
|
- name: screendumps
|
||||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||||
@@ -49,6 +79,10 @@ steps:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
status: failure
|
status: failure
|
||||||
|
path:
|
||||||
|
- "src/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- ".woodpecker/main.yaml"
|
||||||
|
|
||||||
- name: build-and-push
|
- name: build-and-push
|
||||||
image: docker:cli
|
image: docker:cli
|
||||||
@@ -62,8 +96,13 @@ steps:
|
|||||||
when:
|
when:
|
||||||
- branch: main
|
- branch: main
|
||||||
event: push
|
event: push
|
||||||
|
path:
|
||||||
|
- "src/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- "Dockerfile"
|
||||||
|
- ".woodpecker/main.yaml"
|
||||||
|
|
||||||
- name: deploy
|
- name: deploy-staging
|
||||||
image: alpine
|
image: alpine
|
||||||
environment:
|
environment:
|
||||||
SSH_KEY:
|
SSH_KEY:
|
||||||
@@ -77,4 +116,22 @@ steps:
|
|||||||
when:
|
when:
|
||||||
- branch: main
|
- branch: main
|
||||||
event: push
|
event: push
|
||||||
|
path:
|
||||||
|
- "src/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- "Dockerfile"
|
||||||
|
- ".woodpecker/main.yaml"
|
||||||
|
|
||||||
|
- name: deploy-prod
|
||||||
|
image: alpine
|
||||||
|
environment:
|
||||||
|
SSH_KEY:
|
||||||
|
from_secret: deploy_ssh_key
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache openssh-client
|
||||||
|
- mkdir -p ~/.ssh
|
||||||
|
- printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||||
|
- chmod 600 ~/.ssh/id_ed25519
|
||||||
|
- ssh -o StrictHostKeyChecking=no discoman@staging.earthmanrpg.me /opt/gamearray/deploy.sh
|
||||||
|
when:
|
||||||
|
- event: tag
|
||||||
33
.woodpecker/pyswiss.yaml
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
steps:
|
||||||
|
- name: test-pyswiss
|
||||||
|
image: python:3.13-slim
|
||||||
|
environment:
|
||||||
|
SWISSEPH_PATH: /tmp/ephe
|
||||||
|
commands:
|
||||||
|
- apt-get update -qq && apt-get install -y -q gcc g++
|
||||||
|
- pip install -r pyswiss/requirements.txt
|
||||||
|
- cd ./pyswiss
|
||||||
|
- python manage.py test apps.charts
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
path:
|
||||||
|
- "pyswiss/**"
|
||||||
|
- ".woodpecker/pyswiss.yaml"
|
||||||
|
|
||||||
|
- name: deploy-pyswiss
|
||||||
|
image: alpine
|
||||||
|
environment:
|
||||||
|
SSH_KEY:
|
||||||
|
from_secret: pyswiss_deploy
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache openssh-client
|
||||||
|
- mkdir -p ~/.ssh
|
||||||
|
- printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||||
|
- chmod 600 ~/.ssh/id_ed25519
|
||||||
|
- ssh -o StrictHostKeyChecking=no discoman@167.172.154.66 /home/discoman/deploy.sh
|
||||||
|
when:
|
||||||
|
- branch: main
|
||||||
|
event: push
|
||||||
|
path:
|
||||||
|
- "pyswiss/**"
|
||||||
|
- ".woodpecker/pyswiss.yaml"
|
||||||
13
CLAUDE.md
@@ -97,6 +97,19 @@ python src/manage.py test src/functional_tests
|
|||||||
python src/manage.py test src
|
python src/manage.py test src
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Multi-user manual testing — `setup_sig_session`
|
||||||
|
`src/functional_tests/management/commands/setup_sig_session.py`
|
||||||
|
|
||||||
|
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 for pasting into 6 Firefox Multi-Account Container tabs.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Fixed gamers: `founder@test.io` (discoman), `amigo@test.io`, `bud@test.io`, `pal@test.io`, `dude@test.io`, `bro@test.io` — all created as superusers with Earthman deck equipped. URLs use `/lyric/dev-login/<session_key>/` pre-auth pattern.
|
||||||
|
|
||||||
**Test tags:** The only tag in use is `channels` — for async consumer tests that require a live Redis channel layer.
|
**Test tags:** The only tag in use is `channels` — for async consumer tests that require a live Redis channel layer.
|
||||||
- Exclude channels tests: `python src/manage.py test src/apps --exclude-tag=channels`
|
- Exclude channels tests: `python src/manage.py test src/apps --exclude-tag=channels`
|
||||||
- Channels tests only: `python src/manage.py test src/apps --tag=channels`
|
- Channels tests only: `python src/manage.py test src/apps --tag=channels`
|
||||||
|
|||||||
0
pyswiss/apps/charts/__init__.py
Normal file
6
pyswiss/apps/charts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ChartsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.charts'
|
||||||
130
pyswiss/apps/charts/calc.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
Core ephemeris calculation logic — shared by views and management commands.
|
||||||
|
"""
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
import swisseph as swe
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_HOUSE_SYSTEM = 'O' # Porphyry
|
||||||
|
|
||||||
|
SIGNS = [
|
||||||
|
'Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
|
||||||
|
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces',
|
||||||
|
]
|
||||||
|
|
||||||
|
SIGN_ELEMENT = {
|
||||||
|
'Aries': 'Fire', 'Leo': 'Fire', 'Sagittarius': 'Fire',
|
||||||
|
'Taurus': 'Earth', 'Virgo': 'Earth', 'Capricorn': 'Earth',
|
||||||
|
'Gemini': 'Air', 'Libra': 'Air', 'Aquarius': 'Air',
|
||||||
|
'Cancer': 'Water', 'Scorpio': 'Water', 'Pisces': 'Water',
|
||||||
|
}
|
||||||
|
|
||||||
|
ASPECTS = [
|
||||||
|
('Conjunction', 0, 8.0),
|
||||||
|
('Sextile', 60, 6.0),
|
||||||
|
('Square', 90, 8.0),
|
||||||
|
('Trine', 120, 8.0),
|
||||||
|
('Opposition', 180, 10.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
PLANET_CODES = {
|
||||||
|
'Sun': swe.SUN,
|
||||||
|
'Moon': swe.MOON,
|
||||||
|
'Mercury': swe.MERCURY,
|
||||||
|
'Venus': swe.VENUS,
|
||||||
|
'Mars': swe.MARS,
|
||||||
|
'Jupiter': swe.JUPITER,
|
||||||
|
'Saturn': swe.SATURN,
|
||||||
|
'Uranus': swe.URANUS,
|
||||||
|
'Neptune': swe.NEPTUNE,
|
||||||
|
'Pluto': swe.PLUTO,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_ephe_path():
|
||||||
|
ephe_path = getattr(django_settings, 'SWISSEPH_PATH', None)
|
||||||
|
if ephe_path:
|
||||||
|
swe.set_ephe_path(ephe_path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sign(lon):
|
||||||
|
return SIGNS[int(lon // 30) % 12]
|
||||||
|
|
||||||
|
|
||||||
|
def get_julian_day(dt):
|
||||||
|
return swe.julday(
|
||||||
|
dt.year, dt.month, dt.day,
|
||||||
|
dt.hour + dt.minute / 60 + dt.second / 3600,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_planet_positions(jd):
|
||||||
|
flag = swe.FLG_SWIEPH | swe.FLG_SPEED
|
||||||
|
planets = {}
|
||||||
|
for name, code in PLANET_CODES.items():
|
||||||
|
pos, _ = swe.calc_ut(jd, code, flag)
|
||||||
|
degree = pos[0]
|
||||||
|
planets[name] = {
|
||||||
|
'sign': get_sign(degree),
|
||||||
|
'degree': degree,
|
||||||
|
'retrograde': pos[3] < 0,
|
||||||
|
}
|
||||||
|
return planets
|
||||||
|
|
||||||
|
|
||||||
|
def get_element_counts(planets):
|
||||||
|
sign_counts = {s: 0 for s in SIGNS}
|
||||||
|
counts = {'Fire': 0, 'Water': 0, 'Earth': 0, 'Air': 0}
|
||||||
|
|
||||||
|
for data in planets.values():
|
||||||
|
sign = data['sign']
|
||||||
|
counts[SIGN_ELEMENT[sign]] += 1
|
||||||
|
sign_counts[sign] += 1
|
||||||
|
|
||||||
|
# Time: highest planet concentration in a single sign, minus 1
|
||||||
|
counts['Time'] = max(sign_counts.values()) - 1
|
||||||
|
|
||||||
|
# Space: longest consecutive run of occupied signs (circular), minus 1
|
||||||
|
indices = [i for i, s in enumerate(SIGNS) if sign_counts[s] > 0]
|
||||||
|
max_seq = 0
|
||||||
|
for start in range(len(indices)):
|
||||||
|
seq_len = 1
|
||||||
|
for offset in range(1, len(indices)):
|
||||||
|
if (indices[start] + offset) % len(SIGNS) in indices:
|
||||||
|
seq_len += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
max_seq = max(max_seq, seq_len)
|
||||||
|
counts['Space'] = max_seq - 1
|
||||||
|
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_aspects(planets):
|
||||||
|
"""Return a list of aspects between all planet pairs.
|
||||||
|
|
||||||
|
Each entry: {planet1, planet2, type, angle (actual, rounded), orb (rounded)}.
|
||||||
|
Only the first matching aspect type is reported per pair (aspects are
|
||||||
|
well-separated enough that at most one can apply with standard orbs).
|
||||||
|
"""
|
||||||
|
names = list(planets.keys())
|
||||||
|
aspects = []
|
||||||
|
for i, name1 in enumerate(names):
|
||||||
|
for name2 in names[i + 1:]:
|
||||||
|
deg1 = planets[name1]['degree']
|
||||||
|
deg2 = planets[name2]['degree']
|
||||||
|
angle = abs(deg1 - deg2)
|
||||||
|
if angle > 180:
|
||||||
|
angle = 360 - angle
|
||||||
|
for aspect_name, target, max_orb in ASPECTS:
|
||||||
|
orb = abs(angle - target)
|
||||||
|
if orb <= max_orb:
|
||||||
|
aspects.append({
|
||||||
|
'planet1': name1,
|
||||||
|
'planet2': name2,
|
||||||
|
'type': aspect_name,
|
||||||
|
'angle': round(angle, 2),
|
||||||
|
'orb': round(orb, 2),
|
||||||
|
})
|
||||||
|
break
|
||||||
|
return aspects
|
||||||
0
pyswiss/apps/charts/management/__init__.py
Normal file
0
pyswiss/apps/charts/management/commands/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from apps.charts.calc import get_element_counts, get_julian_day, get_planet_positions, set_ephe_path
|
||||||
|
from apps.charts.models import EphemerisSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Pre-compute ephemeris snapshots for a date range (one per day at noon UTC).'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--date-from', required=True, help='Start date (YYYY-MM-DD)')
|
||||||
|
parser.add_argument('--date-to', required=True, help='End date (YYYY-MM-DD, inclusive)')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
set_ephe_path()
|
||||||
|
|
||||||
|
date_from = date.fromisoformat(options['date_from'])
|
||||||
|
date_to = date.fromisoformat(options['date_to'])
|
||||||
|
|
||||||
|
current = date_from
|
||||||
|
count = 0
|
||||||
|
while current <= date_to:
|
||||||
|
dt = datetime(current.year, current.month, current.day,
|
||||||
|
12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
jd = get_julian_day(dt)
|
||||||
|
planets = get_planet_positions(jd)
|
||||||
|
elements = get_element_counts(planets)
|
||||||
|
|
||||||
|
EphemerisSnapshot.objects.update_or_create(
|
||||||
|
dt=dt,
|
||||||
|
defaults={
|
||||||
|
'fire': elements['Fire'],
|
||||||
|
'water': elements['Water'],
|
||||||
|
'earth': elements['Earth'],
|
||||||
|
'air': elements['Air'],
|
||||||
|
'time_el': elements['Time'],
|
||||||
|
'space_el': elements['Space'],
|
||||||
|
'chart_data': {'planets': planets},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
current += timedelta(days=1)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if options['verbosity'] > 0:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f'Created/updated {count} snapshot(s).')
|
||||||
|
)
|
||||||
31
pyswiss/apps/charts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 6.0.4 on 2026-04-13 20:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EphemerisSnapshot',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('dt', models.DateTimeField(db_index=True, unique=True)),
|
||||||
|
('fire', models.PositiveSmallIntegerField()),
|
||||||
|
('water', models.PositiveSmallIntegerField()),
|
||||||
|
('earth', models.PositiveSmallIntegerField()),
|
||||||
|
('air', models.PositiveSmallIntegerField()),
|
||||||
|
('time_el', models.PositiveSmallIntegerField()),
|
||||||
|
('space_el', models.PositiveSmallIntegerField()),
|
||||||
|
('chart_data', models.JSONField()),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['dt'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
pyswiss/apps/charts/migrations/__init__.py
Normal file
36
pyswiss/apps/charts/models.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class EphemerisSnapshot(models.Model):
|
||||||
|
"""Pre-computed chart data for a single point in time.
|
||||||
|
|
||||||
|
Element counts are stored as denormalised columns for fast DB-level range
|
||||||
|
filtering. Full planet/house data lives in chart_data (JSONField) for
|
||||||
|
response serialisation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
dt = models.DateTimeField(unique=True, db_index=True)
|
||||||
|
|
||||||
|
# Denormalised element counts — indexed for range queries
|
||||||
|
fire = models.PositiveSmallIntegerField()
|
||||||
|
water = models.PositiveSmallIntegerField()
|
||||||
|
earth = models.PositiveSmallIntegerField()
|
||||||
|
air = models.PositiveSmallIntegerField()
|
||||||
|
time_el = models.PositiveSmallIntegerField()
|
||||||
|
space_el = models.PositiveSmallIntegerField()
|
||||||
|
|
||||||
|
# Full chart payload
|
||||||
|
chart_data = models.JSONField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['dt']
|
||||||
|
|
||||||
|
def elements_dict(self):
|
||||||
|
return {
|
||||||
|
'Fire': self.fire,
|
||||||
|
'Water': self.water,
|
||||||
|
'Earth': self.earth,
|
||||||
|
'Air': self.air,
|
||||||
|
'Time': self.time_el,
|
||||||
|
'Space': self.space_el,
|
||||||
|
}
|
||||||
0
pyswiss/apps/charts/tests/__init__.py
Normal file
0
pyswiss/apps/charts/tests/integrated/__init__.py
Normal file
159
pyswiss/apps/charts/tests/integrated/test_charts_list.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for GET /api/charts/ — ephemeris range/filter queries.
|
||||||
|
|
||||||
|
These tests drive the EphemerisSnapshot model and list view.
|
||||||
|
Snapshots are created directly in setUp — no live ephemeris calc needed.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||||
|
"""
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.charts.models import EphemerisSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CHART_DATA_STUB = {
|
||||||
|
'planets': {
|
||||||
|
'Sun': {'sign': 'Capricorn', 'degree': 280.37, 'retrograde': False},
|
||||||
|
'Moon': {'sign': 'Aries', 'degree': 15.2, 'retrograde': False},
|
||||||
|
'Mercury': {'sign': 'Capricorn', 'degree': 275.1, 'retrograde': False},
|
||||||
|
'Venus': {'sign': 'Sagittarius','degree': 250.3, 'retrograde': False},
|
||||||
|
'Mars': {'sign': 'Aquarius', 'degree': 308.6, 'retrograde': False},
|
||||||
|
'Jupiter': {'sign': 'Aries', 'degree': 25.9, 'retrograde': False},
|
||||||
|
'Saturn': {'sign': 'Taurus', 'degree': 40.5, 'retrograde': False},
|
||||||
|
'Uranus': {'sign': 'Aquarius', 'degree': 314.2, 'retrograde': False},
|
||||||
|
'Neptune': {'sign': 'Capricorn', 'degree': 303.8, 'retrograde': False},
|
||||||
|
'Pluto': {'sign': 'Sagittarius','degree': 248.4, 'retrograde': False},
|
||||||
|
},
|
||||||
|
'houses': {'cusps': [0]*12, 'asc': 180.0, 'mc': 90.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_snapshot(dt_str, fire=2, water=2, earth=3, air=2, time_el=1, space_el=3,
|
||||||
|
chart_data=None):
|
||||||
|
return EphemerisSnapshot.objects.create(
|
||||||
|
dt=dt_str,
|
||||||
|
fire=fire, water=water, earth=earth, air=air,
|
||||||
|
time_el=time_el, space_el=space_el,
|
||||||
|
chart_data=chart_data or CHART_DATA_STUB,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ChartsListApiTest(TestCase):
|
||||||
|
"""GET /api/charts/ — query pre-computed ephemeris snapshots."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
make_snapshot('2000-01-01T12:00:00Z', fire=3, water=2, earth=3, air=2)
|
||||||
|
make_snapshot('2000-01-02T12:00:00Z', fire=1, water=4, earth=3, air=2)
|
||||||
|
make_snapshot('2000-01-03T12:00:00Z', fire=2, water=2, earth=4, air=2)
|
||||||
|
# Outside the usual date range — should not appear in filtered results
|
||||||
|
make_snapshot('2001-06-15T12:00:00Z', fire=4, water=1, earth=3, air=2)
|
||||||
|
|
||||||
|
def _get(self, params=None):
|
||||||
|
return self.client.get('/api/charts/', params or {})
|
||||||
|
|
||||||
|
# ── guards ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_charts_returns_400_if_date_from_missing(self):
|
||||||
|
response = self._get({'date_to': '2000-01-31'})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_charts_returns_400_if_date_to_missing(self):
|
||||||
|
response = self._get({'date_from': '2000-01-01'})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_charts_returns_400_for_invalid_date_from(self):
|
||||||
|
response = self._get({'date_from': 'not-a-date', 'date_to': '2000-01-31'})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_charts_returns_400_if_date_to_before_date_from(self):
|
||||||
|
response = self._get({'date_from': '2000-01-31', 'date_to': '2000-01-01'})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# ── response shape ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_charts_returns_200_for_valid_params(self):
|
||||||
|
response = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_charts_response_is_json(self):
|
||||||
|
response = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'})
|
||||||
|
self.assertIn('application/json', response['Content-Type'])
|
||||||
|
|
||||||
|
def test_charts_response_has_results_and_count(self):
|
||||||
|
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||||
|
self.assertIn('results', data)
|
||||||
|
self.assertIn('count', data)
|
||||||
|
|
||||||
|
def test_each_result_has_dt_and_elements(self):
|
||||||
|
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||||
|
for result in data['results']:
|
||||||
|
with self.subTest(dt=result.get('dt')):
|
||||||
|
self.assertIn('dt', result)
|
||||||
|
self.assertIn('elements', result)
|
||||||
|
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
|
||||||
|
self.assertIn(key, result['elements'])
|
||||||
|
|
||||||
|
def test_each_result_has_planets(self):
|
||||||
|
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||||
|
for result in data['results']:
|
||||||
|
with self.subTest(dt=result.get('dt')):
|
||||||
|
self.assertIn('planets', result)
|
||||||
|
|
||||||
|
# ── date range filtering ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_charts_returns_only_snapshots_in_date_range(self):
|
||||||
|
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||||
|
self.assertEqual(data['count'], 3)
|
||||||
|
|
||||||
|
def test_charts_count_matches_results_length(self):
|
||||||
|
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-12-31'}).json()
|
||||||
|
self.assertEqual(data['count'], len(data['results']))
|
||||||
|
|
||||||
|
def test_charts_date_range_is_inclusive(self):
|
||||||
|
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-01'}).json()
|
||||||
|
self.assertEqual(data['count'], 1)
|
||||||
|
|
||||||
|
def test_charts_results_ordered_by_dt(self):
|
||||||
|
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||||
|
dts = [r['dt'] for r in data['results']]
|
||||||
|
self.assertEqual(dts, sorted(dts))
|
||||||
|
|
||||||
|
# ── element range filtering ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_charts_filters_by_fire_min(self):
|
||||||
|
# Only the Jan 1 snapshot has fire=3; Jan 2 has fire=1, Jan 3 has fire=2
|
||||||
|
data = self._get({
|
||||||
|
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'fire_min': 3,
|
||||||
|
}).json()
|
||||||
|
self.assertEqual(data['count'], 1)
|
||||||
|
|
||||||
|
def test_charts_filters_by_water_min(self):
|
||||||
|
# Only the Jan 2 snapshot has water=4
|
||||||
|
data = self._get({
|
||||||
|
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'water_min': 4,
|
||||||
|
}).json()
|
||||||
|
self.assertEqual(data['count'], 1)
|
||||||
|
|
||||||
|
def test_charts_filters_by_earth_min(self):
|
||||||
|
# Jan 3 has earth=4; Jan 1 and Jan 2 have earth=3
|
||||||
|
data = self._get({
|
||||||
|
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'earth_min': 4,
|
||||||
|
}).json()
|
||||||
|
self.assertEqual(data['count'], 1)
|
||||||
|
|
||||||
|
def test_charts_multiple_element_filters_are_conjunctive(self):
|
||||||
|
# fire>=2 AND water>=2: Jan 1 (fire=3,water=2) + Jan 3 (fire=2,water=2); not Jan 2 (fire=1)
|
||||||
|
data = self._get({
|
||||||
|
'date_from': '2000-01-01', 'date_to': '2000-01-31',
|
||||||
|
'fire_min': 2, 'water_min': 2,
|
||||||
|
}).json()
|
||||||
|
self.assertEqual(data['count'], 2)
|
||||||
215
pyswiss/apps/charts/tests/integrated/test_views.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for the PySwiss chart calculation API.
|
||||||
|
|
||||||
|
These tests drive the TDD implementation of GET /api/chart/ and GET /api/tz/.
|
||||||
|
They verify the HTTP contract using Django's test client.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||||
|
"""
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# J2000.0 — a well-known reference point: Sun at ~280.37° (Capricorn 10°22')
|
||||||
|
J2000 = '2000-01-01T12:00:00Z'
|
||||||
|
LONDON = {'lat': 51.5074, 'lon': -0.1278}
|
||||||
|
|
||||||
|
# Well-known coordinates with unambiguous timezone results
|
||||||
|
NEW_YORK = {'lat': 40.7128, 'lon': -74.0060} # America/New_York
|
||||||
|
TOKYO = {'lat': 35.6762, 'lon': 139.6503} # Asia/Tokyo
|
||||||
|
REYKJAVIK = {'lat': 64.1355, 'lon': -21.8954} # Atlantic/Reykjavik
|
||||||
|
|
||||||
|
|
||||||
|
class ChartApiTest(TestCase):
|
||||||
|
"""GET /api/chart/ — calculate a natal chart from datetime + coordinates."""
|
||||||
|
|
||||||
|
def _get(self, params):
|
||||||
|
return self.client.get('/api/chart/', params)
|
||||||
|
|
||||||
|
# ── guards ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_chart_returns_400_if_dt_missing(self):
|
||||||
|
response = self._get({'lat': 51.5074, 'lon': -0.1278})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_chart_returns_400_if_lat_missing(self):
|
||||||
|
response = self._get({'dt': J2000, 'lon': -0.1278})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_chart_returns_400_if_lon_missing(self):
|
||||||
|
response = self._get({'dt': J2000, 'lat': 51.5074})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_chart_returns_400_for_invalid_dt_format(self):
|
||||||
|
response = self._get({'dt': 'not-a-date', **LONDON})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_chart_returns_400_for_out_of_range_lat(self):
|
||||||
|
response = self._get({'dt': J2000, 'lat': 999, 'lon': -0.1278})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# ── response shape ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_chart_returns_200_for_valid_params(self):
|
||||||
|
response = self._get({'dt': J2000, **LONDON})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_chart_response_is_json(self):
|
||||||
|
response = self._get({'dt': J2000, **LONDON})
|
||||||
|
self.assertIn('application/json', response['Content-Type'])
|
||||||
|
|
||||||
|
def test_chart_returns_all_ten_planets(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
expected = {
|
||||||
|
'Sun', 'Moon', 'Mercury', 'Venus', 'Mars',
|
||||||
|
'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto',
|
||||||
|
}
|
||||||
|
self.assertEqual(set(data['planets'].keys()), expected)
|
||||||
|
|
||||||
|
def test_each_planet_has_sign_degree_and_retrograde(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
for name, planet in data['planets'].items():
|
||||||
|
with self.subTest(planet=name):
|
||||||
|
self.assertIn('sign', planet)
|
||||||
|
self.assertIn('degree', planet)
|
||||||
|
self.assertIn('retrograde', planet)
|
||||||
|
|
||||||
|
def test_chart_returns_houses(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
houses = data['houses']
|
||||||
|
self.assertEqual(len(houses['cusps']), 12)
|
||||||
|
self.assertIn('asc', houses)
|
||||||
|
self.assertIn('mc', houses)
|
||||||
|
|
||||||
|
def test_chart_returns_six_element_counts(self):
|
||||||
|
"""Fire/Water/Earth/Air are sign-based counts; Time/Space are emergent."""
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
|
||||||
|
with self.subTest(element=key):
|
||||||
|
self.assertIn(key, data['elements'])
|
||||||
|
|
||||||
|
def test_chart_reports_active_house_system(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
self.assertIn('house_system', data)
|
||||||
|
|
||||||
|
# ── calculation correctness ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sun_is_in_capricorn_at_j2000(self):
|
||||||
|
"""Regression: Sun at J2000.0 is ~280.37° — Capricorn."""
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
sun = data['planets']['Sun']
|
||||||
|
self.assertEqual(sun['sign'], 'Capricorn')
|
||||||
|
self.assertAlmostEqual(sun['degree'], 280.37, delta=0.1)
|
||||||
|
|
||||||
|
def test_sun_is_not_retrograde(self):
|
||||||
|
"""The Sun never goes retrograde."""
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
self.assertFalse(data['planets']['Sun']['retrograde'])
|
||||||
|
|
||||||
|
def test_element_counts_sum_to_ten(self):
|
||||||
|
"""All 10 planets are assigned to exactly one classical element."""
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
classical = sum(
|
||||||
|
data['elements'][e] for e in ('Fire', 'Water', 'Earth', 'Air')
|
||||||
|
)
|
||||||
|
self.assertEqual(classical, 10)
|
||||||
|
|
||||||
|
# ── house system ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_default_house_system_is_porphyry(self):
|
||||||
|
"""Porphyry ('O') is the project default — no param needed."""
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
self.assertEqual(data['house_system'], 'O')
|
||||||
|
|
||||||
|
def test_non_superuser_cannot_override_house_system(self):
|
||||||
|
"""House system override is superuser-only; plain requests get 403."""
|
||||||
|
response = self._get({'dt': J2000, **LONDON, 'house_system': 'P'})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
# ── aspects ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_chart_returns_aspects_list(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
self.assertIn('aspects', data)
|
||||||
|
self.assertIsInstance(data['aspects'], list)
|
||||||
|
|
||||||
|
def test_each_aspect_has_required_fields(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
for aspect in data['aspects']:
|
||||||
|
with self.subTest(aspect=aspect):
|
||||||
|
self.assertIn('planet1', aspect)
|
||||||
|
self.assertIn('planet2', aspect)
|
||||||
|
self.assertIn('type', aspect)
|
||||||
|
self.assertIn('angle', aspect)
|
||||||
|
self.assertIn('orb', aspect)
|
||||||
|
|
||||||
|
def test_sun_saturn_trine_present_at_j2000(self):
|
||||||
|
"""Sun ~280.37° (Capricorn) and Saturn ~40.73° (Taurus) are ~120.36° apart — Trine."""
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
pairs = {(a['planet1'], a['planet2'], a['type']) for a in data['aspects']}
|
||||||
|
self.assertIn(('Sun', 'Saturn', 'Trine'), pairs)
|
||||||
|
|
||||||
|
|
||||||
|
class TimezoneApiTest(TestCase):
|
||||||
|
"""GET /api/tz/ — resolve IANA timezone from lat/lon coordinates."""
|
||||||
|
|
||||||
|
def _get(self, params):
|
||||||
|
return self.client.get('/api/tz/', params)
|
||||||
|
|
||||||
|
# ── guards ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_returns_400_if_lat_missing(self):
|
||||||
|
response = self._get({'lon': -74.0060})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_returns_400_if_lon_missing(self):
|
||||||
|
response = self._get({'lat': 40.7128})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_returns_400_for_invalid_lat(self):
|
||||||
|
response = self._get({'lat': 'abc', 'lon': -74.0060})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_returns_400_for_out_of_range_lat(self):
|
||||||
|
response = self._get({'lat': 999, 'lon': -74.0060})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_returns_400_for_out_of_range_lon(self):
|
||||||
|
response = self._get({'lat': 40.7128, 'lon': 999})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# ── response shape ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_returns_200_for_valid_coords(self):
|
||||||
|
response = self._get(NEW_YORK)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_response_is_json(self):
|
||||||
|
response = self._get(NEW_YORK)
|
||||||
|
self.assertIn('application/json', response['Content-Type'])
|
||||||
|
|
||||||
|
def test_response_contains_timezone_key(self):
|
||||||
|
data = self._get(NEW_YORK).json()
|
||||||
|
self.assertIn('timezone', data)
|
||||||
|
|
||||||
|
def test_timezone_is_a_string(self):
|
||||||
|
data = self._get(NEW_YORK).json()
|
||||||
|
self.assertIsInstance(data['timezone'], str)
|
||||||
|
|
||||||
|
# ── correctness ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_new_york_timezone(self):
|
||||||
|
data = self._get(NEW_YORK).json()
|
||||||
|
self.assertEqual(data['timezone'], 'America/New_York')
|
||||||
|
|
||||||
|
def test_tokyo_timezone(self):
|
||||||
|
data = self._get(TOKYO).json()
|
||||||
|
self.assertEqual(data['timezone'], 'Asia/Tokyo')
|
||||||
|
|
||||||
|
def test_reykjavik_timezone(self):
|
||||||
|
data = self._get(REYKJAVIK).json()
|
||||||
|
self.assertEqual(data['timezone'], 'Atlantic/Reykjavik')
|
||||||
0
pyswiss/apps/charts/tests/unit/__init__.py
Normal file
148
pyswiss/apps/charts/tests/unit/test_calc.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for calc.py helper functions.
|
||||||
|
|
||||||
|
These tests verify pure calculation logic without hitting the database
|
||||||
|
or the Swiss Ephemeris — all inputs are fixed synthetic data.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||||
|
"""
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from apps.charts.calc import calculate_aspects
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Synthetic planet data — degrees chosen for predictable aspects
|
||||||
|
# Matches FAKE_PLANETS in test_populate_ephemeris.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FAKE_PLANETS = {
|
||||||
|
'Sun': {'degree': 10.0}, # Aries
|
||||||
|
'Moon': {'degree': 130.0}, # Leo — 120° from Sun → Trine
|
||||||
|
'Mercury': {'degree': 250.0}, # Sagittarius — 120° from Sun → Trine
|
||||||
|
'Venus': {'degree': 40.0}, # Taurus — 90° from Moon → Square
|
||||||
|
'Mars': {'degree': 160.0}, # Virgo — 60° from Neptune → Sextile
|
||||||
|
'Jupiter': {'degree': 280.0}, # Capricorn — 120° from Mars → Trine
|
||||||
|
'Saturn': {'degree': 70.0}, # Gemini — 120° from Uranus → Trine
|
||||||
|
'Uranus': {'degree': 310.0}, # Aquarius — 60° from Sun (wrap) → Sextile
|
||||||
|
'Neptune': {'degree': 100.0}, # Cancer
|
||||||
|
'Pluto': {'degree': 340.0}, # Pisces
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _aspect_pairs(aspects):
|
||||||
|
"""Return a set of (planet1, planet2, type) tuples for easy assertion."""
|
||||||
|
return {(a['planet1'], a['planet2'], a['type']) for a in aspects}
|
||||||
|
|
||||||
|
|
||||||
|
class CalculateAspectsTest(SimpleTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.aspects = calculate_aspects(FAKE_PLANETS)
|
||||||
|
|
||||||
|
# ── return shape ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_returns_a_list(self):
|
||||||
|
self.assertIsInstance(self.aspects, list)
|
||||||
|
|
||||||
|
def test_each_aspect_has_required_keys(self):
|
||||||
|
for aspect in self.aspects:
|
||||||
|
with self.subTest(aspect=aspect):
|
||||||
|
self.assertIn('planet1', aspect)
|
||||||
|
self.assertIn('planet2', aspect)
|
||||||
|
self.assertIn('type', aspect)
|
||||||
|
self.assertIn('angle', aspect)
|
||||||
|
self.assertIn('orb', aspect)
|
||||||
|
|
||||||
|
def test_each_aspect_type_is_a_known_name(self):
|
||||||
|
known = {'Conjunction', 'Sextile', 'Square', 'Trine', 'Opposition'}
|
||||||
|
for aspect in self.aspects:
|
||||||
|
with self.subTest(aspect=aspect):
|
||||||
|
self.assertIn(aspect['type'], known)
|
||||||
|
|
||||||
|
def test_angle_and_orb_are_floats(self):
|
||||||
|
for aspect in self.aspects:
|
||||||
|
with self.subTest(aspect=aspect):
|
||||||
|
self.assertIsInstance(aspect['angle'], float)
|
||||||
|
self.assertIsInstance(aspect['orb'], float)
|
||||||
|
|
||||||
|
def test_no_self_aspects(self):
|
||||||
|
for aspect in self.aspects:
|
||||||
|
self.assertNotEqual(aspect['planet1'], aspect['planet2'])
|
||||||
|
|
||||||
|
def test_no_duplicate_pairs(self):
|
||||||
|
pairs = [(a['planet1'], a['planet2']) for a in self.aspects]
|
||||||
|
self.assertEqual(len(pairs), len(set(pairs)))
|
||||||
|
|
||||||
|
# ── known aspects in FAKE_PLANETS ────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sun_moon_trine(self):
|
||||||
|
"""Moon at 130° is exactly 120° from Sun at 10°."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Sun', 'Moon', 'Trine'), pairs)
|
||||||
|
|
||||||
|
def test_sun_mercury_trine(self):
|
||||||
|
"""Mercury at 250° wraps to 120° from Sun at 10° (360-250+10=120)."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Sun', 'Mercury', 'Trine'), pairs)
|
||||||
|
|
||||||
|
def test_moon_mercury_trine(self):
|
||||||
|
"""Moon 130° → Mercury 250° = 120°."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Moon', 'Mercury', 'Trine'), pairs)
|
||||||
|
|
||||||
|
def test_moon_venus_square(self):
|
||||||
|
"""Moon 130° → Venus 40° = 90°."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Moon', 'Venus', 'Square'), pairs)
|
||||||
|
|
||||||
|
def test_venus_neptune_sextile(self):
|
||||||
|
"""Venus 40° → Neptune 100° = 60°."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Venus', 'Neptune', 'Sextile'), pairs)
|
||||||
|
|
||||||
|
def test_mars_neptune_sextile(self):
|
||||||
|
"""Mars 160° → Neptune 100° = 60°."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Mars', 'Neptune', 'Sextile'), pairs)
|
||||||
|
|
||||||
|
def test_sun_uranus_sextile(self):
|
||||||
|
"""Sun 10° → Uranus 310° — angle = |10-310| = 300° → 360-300 = 60°."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Sun', 'Uranus', 'Sextile'), pairs)
|
||||||
|
|
||||||
|
def test_mars_jupiter_trine(self):
|
||||||
|
"""Mars 160° → Jupiter 280° = 120°."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Mars', 'Jupiter', 'Trine'), pairs)
|
||||||
|
|
||||||
|
def test_saturn_uranus_trine(self):
|
||||||
|
"""Saturn 70° → Uranus 310° = |70-310| = 240° → 360-240 = 120°."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Saturn', 'Uranus', 'Trine'), pairs)
|
||||||
|
|
||||||
|
# ── orb bounds ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_orb_is_within_allowed_maximum(self):
|
||||||
|
max_orbs = {
|
||||||
|
'Conjunction': 8.0,
|
||||||
|
'Sextile': 6.0,
|
||||||
|
'Square': 8.0,
|
||||||
|
'Trine': 8.0,
|
||||||
|
'Opposition': 10.0,
|
||||||
|
}
|
||||||
|
for aspect in self.aspects:
|
||||||
|
with self.subTest(aspect=aspect):
|
||||||
|
self.assertLessEqual(
|
||||||
|
aspect['orb'], max_orbs[aspect['type']],
|
||||||
|
msg=f"{aspect['planet1']}-{aspect['planet2']} orb exceeds maximum",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_exact_trine_has_zero_orb(self):
|
||||||
|
"""Sun-Moon at exactly 120° should report orb of 0.0."""
|
||||||
|
sun_moon = next(
|
||||||
|
a for a in self.aspects
|
||||||
|
if a['planet1'] == 'Sun' and a['planet2'] == 'Moon'
|
||||||
|
)
|
||||||
|
self.assertAlmostEqual(sun_moon['orb'], 0.0, places=5)
|
||||||
99
pyswiss/apps/charts/tests/unit/test_populate_ephemeris.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the populate_ephemeris management command.
|
||||||
|
|
||||||
|
pyswisseph calls are mocked — these tests verify date iteration,
|
||||||
|
snapshot persistence, and idempotency without touching the ephemeris.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.charts.models import EphemerisSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 10 planets covering Fire×3, Earth×3, Air×2, Water×2 (one per sign)
|
||||||
|
# Expected: fire=3, water=2, earth=3, air=2, time=0, space=9
|
||||||
|
FAKE_PLANETS = {
|
||||||
|
'Sun': {'sign': 'Aries', 'degree': 10.0, 'retrograde': False},
|
||||||
|
'Moon': {'sign': 'Leo', 'degree': 130.0, 'retrograde': False},
|
||||||
|
'Mercury': {'sign': 'Sagittarius', 'degree': 250.0, 'retrograde': False},
|
||||||
|
'Venus': {'sign': 'Taurus', 'degree': 40.0, 'retrograde': False},
|
||||||
|
'Mars': {'sign': 'Virgo', 'degree': 160.0, 'retrograde': False},
|
||||||
|
'Jupiter': {'sign': 'Capricorn', 'degree': 280.0, 'retrograde': False},
|
||||||
|
'Saturn': {'sign': 'Gemini', 'degree': 70.0, 'retrograde': False},
|
||||||
|
'Uranus': {'sign': 'Aquarius', 'degree': 310.0, 'retrograde': False},
|
||||||
|
'Neptune': {'sign': 'Cancer', 'degree': 100.0, 'retrograde': False},
|
||||||
|
'Pluto': {'sign': 'Pisces', 'degree': 340.0, 'retrograde': False},
|
||||||
|
}
|
||||||
|
|
||||||
|
PATCH_TARGET = (
|
||||||
|
'apps.charts.management.commands.populate_ephemeris.get_planet_positions'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PopulateEphemerisCommandTest(TestCase):
|
||||||
|
|
||||||
|
def _run(self, date_from, date_to):
|
||||||
|
with patch(PATCH_TARGET, return_value=FAKE_PLANETS):
|
||||||
|
call_command('populate_ephemeris',
|
||||||
|
date_from=date_from, date_to=date_to,
|
||||||
|
verbosity=0)
|
||||||
|
|
||||||
|
# ── date iteration ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_creates_one_snapshot_per_day(self):
|
||||||
|
self._run('2000-01-01', '2000-01-03')
|
||||||
|
self.assertEqual(EphemerisSnapshot.objects.count(), 3)
|
||||||
|
|
||||||
|
def test_single_day_range_creates_one_snapshot(self):
|
||||||
|
self._run('2000-01-01', '2000-01-01')
|
||||||
|
self.assertEqual(EphemerisSnapshot.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_snapshots_are_at_noon_utc(self):
|
||||||
|
self._run('2000-01-01', '2000-01-01')
|
||||||
|
snap = EphemerisSnapshot.objects.get()
|
||||||
|
self.assertEqual(snap.dt, datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
# ── idempotency ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_rerunning_does_not_create_duplicates(self):
|
||||||
|
self._run('2000-01-01', '2000-01-03')
|
||||||
|
self._run('2000-01-01', '2000-01-03')
|
||||||
|
self.assertEqual(EphemerisSnapshot.objects.count(), 3)
|
||||||
|
|
||||||
|
def test_overlapping_ranges_do_not_duplicate(self):
|
||||||
|
self._run('2000-01-01', '2000-01-03')
|
||||||
|
self._run('2000-01-02', '2000-01-05')
|
||||||
|
self.assertEqual(EphemerisSnapshot.objects.count(), 5)
|
||||||
|
|
||||||
|
# ── element counts ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_element_counts_are_persisted(self):
|
||||||
|
self._run('2000-01-01', '2000-01-01')
|
||||||
|
snap = EphemerisSnapshot.objects.get()
|
||||||
|
self.assertEqual(snap.fire, 3)
|
||||||
|
self.assertEqual(snap.water, 2)
|
||||||
|
self.assertEqual(snap.earth, 3)
|
||||||
|
self.assertEqual(snap.air, 2)
|
||||||
|
self.assertEqual(snap.time_el, 0)
|
||||||
|
self.assertEqual(snap.space_el, 9)
|
||||||
|
|
||||||
|
# ── chart_data payload ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_chart_data_contains_planets(self):
|
||||||
|
self._run('2000-01-01', '2000-01-01')
|
||||||
|
snap = EphemerisSnapshot.objects.get()
|
||||||
|
self.assertEqual(snap.chart_data['planets'], FAKE_PLANETS)
|
||||||
8
pyswiss/apps/charts/urls.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('chart/', views.chart, name='chart'),
|
||||||
|
path('charts/', views.charts_list, name='charts_list'),
|
||||||
|
path('tz/', views.timezone_lookup, name='timezone_lookup'),
|
||||||
|
]
|
||||||
143
pyswiss/apps/charts/views.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from django.http import HttpResponse, JsonResponse
|
||||||
|
from timezonefinder import TimezoneFinder
|
||||||
|
|
||||||
|
import swisseph as swe
|
||||||
|
|
||||||
|
from .calc import (
|
||||||
|
DEFAULT_HOUSE_SYSTEM,
|
||||||
|
calculate_aspects,
|
||||||
|
get_element_counts,
|
||||||
|
get_julian_day,
|
||||||
|
get_planet_positions,
|
||||||
|
set_ephe_path,
|
||||||
|
)
|
||||||
|
from .models import EphemerisSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
def chart(request):
|
||||||
|
dt_str = request.GET.get('dt')
|
||||||
|
lat_str = request.GET.get('lat')
|
||||||
|
lon_str = request.GET.get('lon')
|
||||||
|
|
||||||
|
if not dt_str or lat_str is None or lon_str is None:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
|
||||||
|
except ValueError:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
lat = float(lat_str)
|
||||||
|
lon = float(lon_str)
|
||||||
|
except ValueError:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
if not (-90 <= lat <= 90):
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
house_system_param = request.GET.get('house_system')
|
||||||
|
if house_system_param is not None:
|
||||||
|
if not (hasattr(request, 'user') and request.user.is_authenticated
|
||||||
|
and request.user.is_superuser):
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
house_system = house_system_param
|
||||||
|
else:
|
||||||
|
house_system = DEFAULT_HOUSE_SYSTEM
|
||||||
|
|
||||||
|
set_ephe_path()
|
||||||
|
|
||||||
|
jd = get_julian_day(dt)
|
||||||
|
planets = get_planet_positions(jd)
|
||||||
|
|
||||||
|
cusps, ascmc = swe.houses(jd, lat, lon, house_system.encode())
|
||||||
|
houses = {
|
||||||
|
'cusps': list(cusps),
|
||||||
|
'asc': ascmc[0],
|
||||||
|
'mc': ascmc[1],
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'planets': planets,
|
||||||
|
'houses': houses,
|
||||||
|
'elements': get_element_counts(planets),
|
||||||
|
'aspects': calculate_aspects(planets),
|
||||||
|
'house_system': house_system,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
_tf = TimezoneFinder()
|
||||||
|
|
||||||
|
|
||||||
|
def timezone_lookup(request):
|
||||||
|
"""GET /api/tz/ — resolve IANA timezone string from lat/lon.
|
||||||
|
|
||||||
|
Query params: lat (float), lon (float)
|
||||||
|
Returns: { "timezone": "America/New_York" }
|
||||||
|
Returns 404 JSON { "timezone": null } if coordinates fall in international
|
||||||
|
waters (no timezone found) — not an error, just no result.
|
||||||
|
"""
|
||||||
|
lat_str = request.GET.get('lat')
|
||||||
|
lon_str = request.GET.get('lon')
|
||||||
|
|
||||||
|
if lat_str is None or lon_str is None:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
lat = float(lat_str)
|
||||||
|
lon = float(lon_str)
|
||||||
|
except ValueError:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
tz = _tf.timezone_at(lat=lat, lng=lon)
|
||||||
|
return JsonResponse({'timezone': tz})
|
||||||
|
|
||||||
|
|
||||||
|
def charts_list(request):
|
||||||
|
date_from_str = request.GET.get('date_from')
|
||||||
|
date_to_str = request.GET.get('date_to')
|
||||||
|
|
||||||
|
if not date_from_str or not date_to_str:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
date_from = datetime.strptime(date_from_str, '%Y-%m-%d').replace(
|
||||||
|
tzinfo=timezone.utc)
|
||||||
|
date_to = datetime.strptime(date_to_str, '%Y-%m-%d').replace(
|
||||||
|
hour=23, minute=59, second=59, tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
if date_to < date_from:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
qs = EphemerisSnapshot.objects.filter(dt__gte=date_from, dt__lte=date_to)
|
||||||
|
|
||||||
|
element_fields = {
|
||||||
|
'fire_min': 'fire', 'water_min': 'water',
|
||||||
|
'earth_min': 'earth', 'air_min': 'air',
|
||||||
|
'time_min': 'time_el', 'space_min': 'space_el',
|
||||||
|
}
|
||||||
|
for param, field in element_fields.items():
|
||||||
|
value = request.GET.get(param)
|
||||||
|
if value is not None:
|
||||||
|
try:
|
||||||
|
qs = qs.filter(**{f'{field}__gte': int(value)})
|
||||||
|
except ValueError:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
results = [
|
||||||
|
{
|
||||||
|
'dt': snap.dt.isoformat(),
|
||||||
|
'elements': snap.elements_dict(),
|
||||||
|
'planets': snap.chart_data.get('planets', {}),
|
||||||
|
}
|
||||||
|
for snap in qs
|
||||||
|
]
|
||||||
|
|
||||||
|
return JsonResponse({'results': results, 'count': len(results)})
|
||||||
0
pyswiss/core/__init__.py
Normal file
49
pyswiss/core/settings.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY', 'pyswiss-dev-only-key-replace-in-production')
|
||||||
|
DEBUG = os.environ.get('DEBUG', 'true').lower() != 'false'
|
||||||
|
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'corsheaders',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'apps.charts',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGIN_REGEXES = [
|
||||||
|
r'^https://.*\.earthmanrpg\.me$',
|
||||||
|
r'^http://localhost(:\d+)?$',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'core.urls'
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
STATIC_URL = '/static/'
|
||||||
|
MEDIA_URL = '/media/'
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
|
# Swiss Ephemeris data files.
|
||||||
|
# Override via SWISSEPH_PATH env var on staging/production.
|
||||||
|
SWISSEPH_PATH = os.environ.get(
|
||||||
|
'SWISSEPH_PATH',
|
||||||
|
r'D:\OneDrive\Desktop\potentium\implicateOrder\libraries\swisseph-master\ephe',
|
||||||
|
)
|
||||||
5
pyswiss/core/urls.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('api/', include('apps.charts.urls')),
|
||||||
|
]
|
||||||
6
pyswiss/core/wsgi.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import os
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
20
pyswiss/manage.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and available "
|
||||||
|
"on your PYTHONPATH environment variable? Did you forget to activate "
|
||||||
|
"a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
5
pyswiss/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
django==6.0.4
|
||||||
|
django-cors-headers==4.3.1
|
||||||
|
gunicorn==23.0.0
|
||||||
|
pyswisseph==2.10.3.2
|
||||||
|
timezonefinder==8.2.2
|
||||||
@@ -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
|
||||||
|
|||||||
0
src/apps/ap/__init__.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/integrated/__init__.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
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
@@ -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
@@ -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
@@ -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):
|
||||||
@@ -27,7 +27,13 @@ def billboard(request):
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
recent_events = (
|
recent_events = (
|
||||||
list(recent_room.events.select_related("actor").order_by("-timestamp")[:36])[::-1]
|
list(
|
||||||
|
recent_room.events
|
||||||
|
.select_related("actor")
|
||||||
|
.exclude(verb=GameEvent.SIG_UNREADY)
|
||||||
|
.exclude(verb=GameEvent.SIG_READY, data__retracted=True)
|
||||||
|
.order_by("-timestamp")[:36]
|
||||||
|
)[::-1]
|
||||||
if recent_room else []
|
if recent_room else []
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
18
src/apps/drama/migrations/0003_alter_gameevent_verb.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-12 23:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('drama', '0002_scrollposition'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='gameevent',
|
||||||
|
name='verb',
|
||||||
|
field=models.CharField(choices=[('room_created', 'Room created'), ('slot_reserved', 'Gate slot reserved'), ('slot_filled', 'Gate slot filled'), ('slot_returned', 'Gate slot returned'), ('slot_released', 'Gate slot released'), ('invite_sent', 'Invite sent'), ('role_select_started', 'Role select started'), ('role_selected', 'Role selected'), ('roles_revealed', 'Roles revealed'), ('sig_ready', 'Sig claim staked'), ('sig_unready', 'Sig claim withdrawn')], max_length=30),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
# ── Default gender-neutral pronouns (Baltimore original) ──────────────────────
|
||||||
|
# Later: replace with per-actor lookup when User model gains a pronouns field.
|
||||||
|
PRONOUN_SUBJ = "yo"
|
||||||
|
PRONOUN_OBJ = "yo"
|
||||||
|
PRONOUN_POSS = "yos"
|
||||||
|
|
||||||
|
|
||||||
class GameEvent(models.Model):
|
class GameEvent(models.Model):
|
||||||
# Gate phase
|
# Gate phase
|
||||||
@@ -14,6 +20,9 @@ class GameEvent(models.Model):
|
|||||||
ROLE_SELECT_STARTED = "role_select_started"
|
ROLE_SELECT_STARTED = "role_select_started"
|
||||||
ROLE_SELECTED = "role_selected"
|
ROLE_SELECTED = "role_selected"
|
||||||
ROLES_REVEALED = "roles_revealed"
|
ROLES_REVEALED = "roles_revealed"
|
||||||
|
# Sig Select phase
|
||||||
|
SIG_READY = "sig_ready"
|
||||||
|
SIG_UNREADY = "sig_unready"
|
||||||
|
|
||||||
VERB_CHOICES = [
|
VERB_CHOICES = [
|
||||||
(ROOM_CREATED, "Room created"),
|
(ROOM_CREATED, "Room created"),
|
||||||
@@ -25,6 +34,8 @@ class GameEvent(models.Model):
|
|||||||
(ROLE_SELECT_STARTED, "Role select started"),
|
(ROLE_SELECT_STARTED, "Role select started"),
|
||||||
(ROLE_SELECTED, "Role selected"),
|
(ROLE_SELECTED, "Role selected"),
|
||||||
(ROLES_REVEALED, "Roles revealed"),
|
(ROLES_REVEALED, "Roles revealed"),
|
||||||
|
(SIG_READY, "Sig claim staked"),
|
||||||
|
(SIG_UNREADY, "Sig claim withdrawn"),
|
||||||
]
|
]
|
||||||
|
|
||||||
room = models.ForeignKey(
|
room = models.ForeignKey(
|
||||||
@@ -53,7 +64,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:
|
||||||
@@ -71,13 +82,65 @@ class GameEvent(models.Model):
|
|||||||
"PC": "Player", "BC": "Builder", "SC": "Shepherd",
|
"PC": "Player", "BC": "Builder", "SC": "Shepherd",
|
||||||
"AC": "Alchemist", "NC": "Narrator", "EC": "Economist",
|
"AC": "Alchemist", "NC": "Narrator", "EC": "Economist",
|
||||||
}
|
}
|
||||||
|
_chair_order = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||||||
|
_ordinals = ["1st", "2nd", "3rd", "4th", "5th", "6th"]
|
||||||
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}"
|
try:
|
||||||
|
ordinal = _ordinals[_chair_order.index(code)]
|
||||||
|
except ValueError:
|
||||||
|
ordinal = "?"
|
||||||
|
return f"assumes {ordinal} Chair; yo will start the game as the {role}."
|
||||||
if self.verb == self.ROLES_REVEALED:
|
if self.verb == self.ROLES_REVEALED:
|
||||||
return "All roles assigned"
|
return "All roles assigned"
|
||||||
|
if self.verb == self.SIG_READY:
|
||||||
|
card_name = d.get("card_name", "a card")
|
||||||
|
corner_rank = d.get("corner_rank", "")
|
||||||
|
suit_icon = d.get("suit_icon", "")
|
||||||
|
if corner_rank:
|
||||||
|
icon_html = f' <i class="fa-solid {suit_icon}"></i>' if suit_icon else ""
|
||||||
|
abbrev = f" ({corner_rank}{icon_html})"
|
||||||
|
else:
|
||||||
|
abbrev = ""
|
||||||
|
return f"embodies as {PRONOUN_POSS} Significator the {card_name}{abbrev}."
|
||||||
|
if self.verb == self.SIG_UNREADY:
|
||||||
|
return f"disembodies {PRONOUN_POSS} Significator."
|
||||||
return self.verb
|
return self.verb
|
||||||
|
|
||||||
|
@property
|
||||||
|
def struck(self):
|
||||||
|
"""True when this SIG_READY event was subsequently retracted (WAIT NVM)."""
|
||||||
|
return self.data.get("retracted", False)
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|||||||
@@ -40,6 +40,49 @@ class GameEventModelTest(TestCase):
|
|||||||
self.assertIn("actor@test.io", str(event))
|
self.assertIn("actor@test.io", str(event))
|
||||||
self.assertIn(GameEvent.ROLE_SELECTED, str(event))
|
self.assertIn(GameEvent.ROLE_SELECTED, str(event))
|
||||||
|
|
||||||
|
# ── to_prose — ROLE_SELECTED ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_role_selected_prose_uses_ordinal_chair(self):
|
||||||
|
for role, ordinal in [("PC", "1st"), ("NC", "2nd"), ("EC", "3rd"),
|
||||||
|
("SC", "4th"), ("AC", "5th"), ("BC", "6th")]:
|
||||||
|
with self.subTest(role=role):
|
||||||
|
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
|
||||||
|
role=role, role_display="")
|
||||||
|
self.assertIn(f"assumes {ordinal} Chair", event.to_prose())
|
||||||
|
|
||||||
|
def test_role_selected_prose_includes_role_name(self):
|
||||||
|
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
|
||||||
|
role="PC", role_display="Player")
|
||||||
|
prose = event.to_prose()
|
||||||
|
self.assertIn("Player", prose)
|
||||||
|
self.assertIn("yo will start the game", prose)
|
||||||
|
|
||||||
|
# ── to_prose — SIG_READY ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sig_ready_prose_embodies_card_with_rank_and_icon(self):
|
||||||
|
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
||||||
|
card_name="Maid of Brands", corner_rank="M",
|
||||||
|
suit_icon="fa-wand-sparkles")
|
||||||
|
prose = event.to_prose()
|
||||||
|
self.assertIn("embodies as yos Significator the Maid of Brands", prose)
|
||||||
|
self.assertIn("(M", prose)
|
||||||
|
self.assertIn("fa-wand-sparkles", prose)
|
||||||
|
|
||||||
|
def test_sig_ready_prose_omits_icon_when_none(self):
|
||||||
|
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
||||||
|
card_name="The Wanderer", corner_rank="0", suit_icon="")
|
||||||
|
prose = event.to_prose()
|
||||||
|
self.assertIn("embodies as yos Significator the The Wanderer (0)", prose)
|
||||||
|
self.assertNotIn("fa-", prose)
|
||||||
|
|
||||||
|
def test_sig_ready_prose_degrades_without_corner_rank(self):
|
||||||
|
# Old events recorded before this change have no corner_rank key
|
||||||
|
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
||||||
|
card_name="Maid of Brands")
|
||||||
|
prose = event.to_prose()
|
||||||
|
self.assertIn("embodies as yos Significator the Maid of Brands", prose)
|
||||||
|
self.assertNotIn("(", prose)
|
||||||
|
|
||||||
def test_str_without_actor_shows_system(self):
|
def test_str_without_actor_shows_system(self):
|
||||||
event = record(self.room, GameEvent.ROLES_REVEALED)
|
event = record(self.room, GameEvent.ROLES_REVEALED)
|
||||||
self.assertIn("system", str(event))
|
self.assertIn("system", str(event))
|
||||||
@@ -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,32 @@ 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 countdown_start(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def countdown_cancel(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def polarity_room_done(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def pick_sky_available(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def cursor_move(self, event):
|
||||||
await self.send_json(event)
|
await self.send_json(event)
|
||||||
|
|||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
33
src/apps/epic/migrations/0031_sig_ready_sky_select.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-09 04:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0030_sigreservation_seat_fk'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='room',
|
||||||
|
name='sig_select_started_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sigreservation',
|
||||||
|
name='countdown_remaining',
|
||||||
|
field=models.IntegerField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='sigreservation',
|
||||||
|
name='ready',
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='room',
|
||||||
|
name='table_status',
|
||||||
|
field=models.CharField(blank=True, choices=[('ROLE_SELECT', 'Role Select'), ('SIG_SELECT', 'Significator Select'), ('SKY_SELECT', 'Sky Select'), ('IN_GAME', 'In Game')], max_length=20, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
65
src/apps/epic/migrations/0032_astro_reference_tables.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-14 05:27
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0031_sig_ready_sky_select'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AspectType',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=20, unique=True)),
|
||||||
|
('symbol', models.CharField(max_length=5)),
|
||||||
|
('angle', models.PositiveSmallIntegerField()),
|
||||||
|
('orb', models.FloatField()),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['angle'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='HouseLabel',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('number', models.PositiveSmallIntegerField(unique=True)),
|
||||||
|
('name', models.CharField(max_length=30)),
|
||||||
|
('keywords', models.CharField(blank=True, max_length=100)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['number'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Planet',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=20, unique=True)),
|
||||||
|
('symbol', models.CharField(max_length=5)),
|
||||||
|
('order', models.PositiveSmallIntegerField(unique=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['order'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Sign',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=20, unique=True)),
|
||||||
|
('symbol', models.CharField(max_length=5)),
|
||||||
|
('element', models.CharField(choices=[('Fire', 'Fire'), ('Earth', 'Earth'), ('Air', 'Air'), ('Water', 'Water')], max_length=5)),
|
||||||
|
('modality', models.CharField(choices=[('Cardinal', 'Cardinal'), ('Fixed', 'Fixed'), ('Mutable', 'Mutable')], max_length=8)),
|
||||||
|
('order', models.PositiveSmallIntegerField(unique=True)),
|
||||||
|
('start_degree', models.FloatField()),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['order'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
106
src/apps/epic/migrations/0033_seed_astro_reference_tables.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""
|
||||||
|
Data migration: seed Sign, Planet, AspectType, and HouseLabel tables.
|
||||||
|
|
||||||
|
These are stable astrological reference rows — never user-edited.
|
||||||
|
The data matches the constants in pyswiss/apps/charts/calc.py so that
|
||||||
|
the proxy view and D3 wheel share a single source of truth.
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
# ── Signs ────────────────────────────────────────────────────────────────────
|
||||||
|
# (order, name, symbol, element, modality, start_degree)
|
||||||
|
SIGNS = [
|
||||||
|
(0, 'Aries', '♈', 'Fire', 'Cardinal', 0.0),
|
||||||
|
(1, 'Taurus', '♉', 'Earth', 'Fixed', 30.0),
|
||||||
|
(2, 'Gemini', '♊', 'Air', 'Mutable', 60.0),
|
||||||
|
(3, 'Cancer', '♋', 'Water', 'Cardinal', 90.0),
|
||||||
|
(4, 'Leo', '♌', 'Fire', 'Fixed', 120.0),
|
||||||
|
(5, 'Virgo', '♍', 'Earth', 'Mutable', 150.0),
|
||||||
|
(6, 'Libra', '♎', 'Air', 'Cardinal', 180.0),
|
||||||
|
(7, 'Scorpio', '♏', 'Water', 'Fixed', 210.0),
|
||||||
|
(8, 'Sagittarius', '♐', 'Fire', 'Mutable', 240.0),
|
||||||
|
(9, 'Capricorn', '♑', 'Earth', 'Cardinal', 270.0),
|
||||||
|
(10, 'Aquarius', '♒', 'Air', 'Fixed', 300.0),
|
||||||
|
(11, 'Pisces', '♓', 'Water', 'Mutable', 330.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Planets ───────────────────────────────────────────────────────────────────
|
||||||
|
# (order, name, symbol)
|
||||||
|
PLANETS = [
|
||||||
|
(0, 'Sun', '☉'),
|
||||||
|
(1, 'Moon', '☽'),
|
||||||
|
(2, 'Mercury', '☿'),
|
||||||
|
(3, 'Venus', '♀'),
|
||||||
|
(4, 'Mars', '♂'),
|
||||||
|
(5, 'Jupiter', '♃'),
|
||||||
|
(6, 'Saturn', '♄'),
|
||||||
|
(7, 'Uranus', '♅'),
|
||||||
|
(8, 'Neptune', '♆'),
|
||||||
|
(9, 'Pluto', '♇'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Aspect types ──────────────────────────────────────────────────────────────
|
||||||
|
# (name, symbol, angle, orb) — mirrors ASPECTS constant in pyswiss calc.py
|
||||||
|
ASPECT_TYPES = [
|
||||||
|
('Conjunction', '☌', 0, 8.0),
|
||||||
|
('Sextile', '⚹', 60, 6.0),
|
||||||
|
('Square', '□', 90, 8.0),
|
||||||
|
('Trine', '△', 120, 8.0),
|
||||||
|
('Opposition', '☍', 180, 10.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── House labels (distinctions) ───────────────────────────────────────────────
|
||||||
|
# (number, name, keywords)
|
||||||
|
HOUSE_LABELS = [
|
||||||
|
(1, 'Self', 'identity, appearance, first impressions'),
|
||||||
|
(2, 'Worth', 'possessions, values, finances'),
|
||||||
|
(3, 'Education', 'communication, siblings, short journeys'),
|
||||||
|
(4, 'Family', 'home, roots, ancestry'),
|
||||||
|
(5, 'Creation', 'creativity, romance, children, pleasure'),
|
||||||
|
(6, 'Ritual', 'service, health, daily routines'),
|
||||||
|
(7, 'Cooperation', 'partnerships, marriage, open enemies'),
|
||||||
|
(8, 'Regeneration', 'transformation, shared resources, death'),
|
||||||
|
(9, 'Enterprise', 'philosophy, travel, higher learning'),
|
||||||
|
(10, 'Career', 'public life, reputation, authority'),
|
||||||
|
(11, 'Reward', 'friends, groups, aspirations'),
|
||||||
|
(12, 'Reprisal', 'hidden matters, karma, self-undoing'),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def forward(apps, schema_editor):
|
||||||
|
Sign = apps.get_model('epic', 'Sign')
|
||||||
|
Planet = apps.get_model('epic', 'Planet')
|
||||||
|
AspectType = apps.get_model('epic', 'AspectType')
|
||||||
|
HouseLabel = apps.get_model('epic', 'HouseLabel')
|
||||||
|
|
||||||
|
for order, name, symbol, element, modality, start_degree in SIGNS:
|
||||||
|
Sign.objects.create(
|
||||||
|
order=order, name=name, symbol=symbol,
|
||||||
|
element=element, modality=modality, start_degree=start_degree,
|
||||||
|
)
|
||||||
|
|
||||||
|
for order, name, symbol in PLANETS:
|
||||||
|
Planet.objects.create(order=order, name=name, symbol=symbol)
|
||||||
|
|
||||||
|
for name, symbol, angle, orb in ASPECT_TYPES:
|
||||||
|
AspectType.objects.create(name=name, symbol=symbol, angle=angle, orb=orb)
|
||||||
|
|
||||||
|
for number, name, keywords in HOUSE_LABELS:
|
||||||
|
HouseLabel.objects.create(number=number, name=name, keywords=keywords)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse(apps, schema_editor):
|
||||||
|
for model_name in ('Sign', 'Planet', 'AspectType', 'HouseLabel'):
|
||||||
|
apps.get_model('epic', model_name).objects.all().delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0032_astro_reference_tables'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forward, reverse_code=reverse),
|
||||||
|
]
|
||||||
35
src/apps/epic/migrations/0034_character_model.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-14 05:29
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0033_seed_astro_reference_tables'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Character',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('birth_dt', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('birth_lat', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
|
||||||
|
('birth_lon', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
|
||||||
|
('birth_place', models.CharField(blank=True, max_length=200)),
|
||||||
|
('house_system', models.CharField(choices=[('O', 'Porphyry'), ('P', 'Placidus'), ('K', 'Koch'), ('W', 'Whole Sign')], default='O', max_length=1)),
|
||||||
|
('chart_data', models.JSONField(blank=True, null=True)),
|
||||||
|
('celtic_cross', models.JSONField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('confirmed_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('retired_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('seat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='characters', to='epic.tableseat')),
|
||||||
|
('significator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='character_significators', to='epic.tarotcard')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -32,10 +33,12 @@ class Room(models.Model):
|
|||||||
|
|
||||||
ROLE_SELECT = "ROLE_SELECT"
|
ROLE_SELECT = "ROLE_SELECT"
|
||||||
SIG_SELECT = "SIG_SELECT"
|
SIG_SELECT = "SIG_SELECT"
|
||||||
|
SKY_SELECT = "SKY_SELECT"
|
||||||
IN_GAME = "IN_GAME"
|
IN_GAME = "IN_GAME"
|
||||||
TABLE_STATUS_CHOICES = [
|
TABLE_STATUS_CHOICES = [
|
||||||
(ROLE_SELECT, "Role Select"),
|
(ROLE_SELECT, "Role Select"),
|
||||||
(SIG_SELECT, "Significator Select"),
|
(SIG_SELECT, "Significator Select"),
|
||||||
|
(SKY_SELECT, "Sky Select"),
|
||||||
(IN_GAME, "In Game"),
|
(IN_GAME, "In Game"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -49,6 +52,7 @@ class Room(models.Model):
|
|||||||
table_status = models.CharField(
|
table_status = models.CharField(
|
||||||
max_length=20, choices=TABLE_STATUS_CHOICES, null=True, blank=True
|
max_length=20, choices=TABLE_STATUS_CHOICES, null=True, blank=True
|
||||||
)
|
)
|
||||||
|
sig_select_started_at = models.DateTimeField(null=True, blank=True)
|
||||||
renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7))
|
renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7))
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
board_state = models.JSONField(default=dict)
|
board_state = models.JSONField(default=dict)
|
||||||
@@ -204,22 +208,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(
|
||||||
@@ -227,14 +239,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"]
|
||||||
@@ -276,16 +290,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
|
||||||
|
|
||||||
@@ -327,29 +351,66 @@ class TarotDeck(models.Model):
|
|||||||
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)
|
||||||
|
ready = models.BooleanField(default=False)
|
||||||
|
countdown_remaining = models.IntegerField(null=True, blank=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 ─────────────────────────────────────────────────
|
# ── Significator deck helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
def sig_deck_cards(room):
|
def sig_deck_cards(room):
|
||||||
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
|
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
|
||||||
|
|
||||||
PC/BC pair → WANDS + PENTACLES court cards (numbers 11–14): 8 unique
|
PC/BC pair → BRANDS/WANDS + CROWNS Middle Arcana court cards (11–14): 8 unique
|
||||||
SC/AC pair → SWORDS + CUPS court cards (numbers 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
|
NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique
|
||||||
Total: 18 unique × 2 (levity + gravity piles) = 36 cards.
|
Total: 18 unique × 2 (levity + gravity piles) = 36 cards.
|
||||||
"""
|
"""
|
||||||
deck_variant = room.owner.equipped_deck
|
deck_variant = room.owner.equipped_deck
|
||||||
if deck_variant is None:
|
if deck_variant is None:
|
||||||
return []
|
return []
|
||||||
wands_pentacles = list(TarotCard.objects.filter(
|
wands_crowns = list(TarotCard.objects.filter(
|
||||||
deck_variant=deck_variant,
|
deck_variant=deck_variant,
|
||||||
arcana=TarotCard.MINOR,
|
arcana=TarotCard.MIDDLE,
|
||||||
suit__in=[TarotCard.WANDS, TarotCard.PENTACLES],
|
suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS],
|
||||||
number__in=[11, 12, 13, 14],
|
number__in=[11, 12, 13, 14],
|
||||||
))
|
))
|
||||||
swords_cups = list(TarotCard.objects.filter(
|
swords_cups = list(TarotCard.objects.filter(
|
||||||
deck_variant=deck_variant,
|
deck_variant=deck_variant,
|
||||||
arcana=TarotCard.MINOR,
|
arcana=TarotCard.MIDDLE,
|
||||||
suit__in=[TarotCard.SWORDS, TarotCard.CUPS],
|
suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS],
|
||||||
number__in=[11, 12, 13, 14],
|
number__in=[11, 12, 13, 14],
|
||||||
))
|
))
|
||||||
major = list(TarotCard.objects.filter(
|
major = list(TarotCard.objects.filter(
|
||||||
@@ -357,10 +418,45 @@ def sig_deck_cards(room):
|
|||||||
arcana=TarotCard.MAJOR,
|
arcana=TarotCard.MAJOR,
|
||||||
number__in=[0, 1],
|
number__in=[0, 1],
|
||||||
))
|
))
|
||||||
unique_cards = wands_pentacles + swords_cups + major # 18 unique
|
unique_cards = wands_crowns + swords_cups + major # 18 unique
|
||||||
return unique_cards + unique_cards # × 2 = 36
|
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):
|
def sig_seat_order(room):
|
||||||
"""Return TableSeats in canonical PC→NC→EC→SC→AC→BC order."""
|
"""Return TableSeats in canonical PC→NC→EC→SC→AC→BC order."""
|
||||||
_order = {r: i for i, r in enumerate(SIG_SEAT_ORDER)}
|
_order = {r: i for i, r in enumerate(SIG_SEAT_ORDER)}
|
||||||
@@ -374,3 +470,141 @@ def active_sig_seat(room):
|
|||||||
if seat.significator_id is None:
|
if seat.significator_id is None:
|
||||||
return seat
|
return seat
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Astrological reference tables (seeded, never user-edited) ─────────────────
|
||||||
|
|
||||||
|
class Sign(models.Model):
|
||||||
|
FIRE = 'Fire'
|
||||||
|
EARTH = 'Earth'
|
||||||
|
AIR = 'Air'
|
||||||
|
WATER = 'Water'
|
||||||
|
ELEMENT_CHOICES = [(e, e) for e in (FIRE, EARTH, AIR, WATER)]
|
||||||
|
|
||||||
|
CARDINAL = 'Cardinal'
|
||||||
|
FIXED = 'Fixed'
|
||||||
|
MUTABLE = 'Mutable'
|
||||||
|
MODALITY_CHOICES = [(m, m) for m in (CARDINAL, FIXED, MUTABLE)]
|
||||||
|
|
||||||
|
name = models.CharField(max_length=20, unique=True)
|
||||||
|
symbol = models.CharField(max_length=5) # ♈ ♉ … ♓
|
||||||
|
element = models.CharField(max_length=5, choices=ELEMENT_CHOICES)
|
||||||
|
modality = models.CharField(max_length=8, choices=MODALITY_CHOICES)
|
||||||
|
order = models.PositiveSmallIntegerField(unique=True) # 0–11, Aries first
|
||||||
|
start_degree = models.FloatField() # 0, 30, 60 … 330
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['order']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Planet(models.Model):
|
||||||
|
name = models.CharField(max_length=20, unique=True)
|
||||||
|
symbol = models.CharField(max_length=5) # ☉ ☽ ☿ ♀ ♂ ♃ ♄ ♅ ♆ ♇
|
||||||
|
order = models.PositiveSmallIntegerField(unique=True) # 0–9, Sun first
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['order']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class AspectType(models.Model):
|
||||||
|
name = models.CharField(max_length=20, unique=True)
|
||||||
|
symbol = models.CharField(max_length=5) # ☌ ⚹ □ △ ☍
|
||||||
|
angle = models.PositiveSmallIntegerField() # 0, 60, 90, 120, 180
|
||||||
|
orb = models.FloatField() # max allowed orb in degrees
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['angle']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class HouseLabel(models.Model):
|
||||||
|
"""Life-area label for each of the 12 astrological houses (distinctions)."""
|
||||||
|
|
||||||
|
number = models.PositiveSmallIntegerField(unique=True) # 1–12
|
||||||
|
name = models.CharField(max_length=30)
|
||||||
|
keywords = models.CharField(max_length=100, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['number']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.number}: {self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Character ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class Character(models.Model):
|
||||||
|
"""A gamer's player-character for one seat in one game session.
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
- Created (draft) when gamer opens PICK SKY overlay.
|
||||||
|
- confirmed_at set on confirm → locked.
|
||||||
|
- retired_at set on retirement → archived (seat may hold a new Character).
|
||||||
|
|
||||||
|
Active character for a seat: confirmed_at__isnull=False, retired_at__isnull=True.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PORPHYRY = 'O'
|
||||||
|
PLACIDUS = 'P'
|
||||||
|
KOCH = 'K'
|
||||||
|
WHOLE = 'W'
|
||||||
|
HOUSE_SYSTEM_CHOICES = [
|
||||||
|
(PORPHYRY, 'Porphyry'),
|
||||||
|
(PLACIDUS, 'Placidus'),
|
||||||
|
(KOCH, 'Koch'),
|
||||||
|
(WHOLE, 'Whole Sign'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── seat relationship ─────────────────────────────────────────────────
|
||||||
|
seat = models.ForeignKey(
|
||||||
|
TableSeat, on_delete=models.CASCADE, related_name='characters',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── significator (set at PICK SKY) ────────────────────────────────────
|
||||||
|
significator = models.ForeignKey(
|
||||||
|
TarotCard, null=True, blank=True,
|
||||||
|
on_delete=models.SET_NULL, related_name='character_significators',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── natus input (what the gamer entered) ─────────────────────────────
|
||||||
|
birth_dt = models.DateTimeField(null=True, blank=True) # UTC
|
||||||
|
birth_lat = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||||
|
birth_lon = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||||
|
birth_place = models.CharField(max_length=200, blank=True) # display string only
|
||||||
|
house_system = models.CharField(
|
||||||
|
max_length=1, choices=HOUSE_SYSTEM_CHOICES, default=PORPHYRY,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── computed natus snapshot (full PySwiss response) ───────────────────
|
||||||
|
chart_data = models.JSONField(null=True, blank=True)
|
||||||
|
|
||||||
|
# ── celtic cross spread (added at PICK SEA) ───────────────────────────
|
||||||
|
celtic_cross = models.JSONField(null=True, blank=True)
|
||||||
|
|
||||||
|
# ── lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
confirmed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
retired_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
status = 'confirmed' if self.confirmed_at else 'draft'
|
||||||
|
return f"Character(seat={self.seat_id}, {status})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_confirmed(self):
|
||||||
|
return self.confirmed_at is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_active(self):
|
||||||
|
return self.confirmed_at is not None and self.retired_at is 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 |
62
src/apps/epic/static/apps/epic/icons/cards-sigs/Blank.svg
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?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: #ead08e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #e1bc70;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-3 {
|
||||||
|
fill: #c8a363;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-4 {
|
||||||
|
fill: #d2ab67;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-5 {
|
||||||
|
fill: #e7c278;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-6 {
|
||||||
|
fill: none;
|
||||||
|
stroke: #381507;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 2.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-7 {
|
||||||
|
fill: #dfbc6d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-8 {
|
||||||
|
fill: #cfa864;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-9 {
|
||||||
|
fill: #f4dfa9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-10 {
|
||||||
|
fill: #d0a965;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-9" d="M176.79,209.43l-2.01.07-2.45,11.42c.4,3.9-.63,6.69-1.9,10.36l-3.61,10.39c.59.78.74,1.77,1.37.7l7.89-16.39,7.62-10.14c2.27.08,3.91.46,5.82.02-.48,1.32-.34,1.86-.19,3.24,2.27-.16,4.39.54,6.42,1.88,1.42-1.07,2.79-1.65,4.6-1.66h3.49c.68.88-.13,2.84-1.05,3.79-.72.74.87,3.35,1.92,3.32l6.17-.17c1.75,1.96,1.05,4.36.98,6.82-.21,7.02-6.11,2.65-10.61,6.97-.68.65-.91,2.18-.23,2.68.84.62,4.26-1.19,5.18,1.09.05.12-2.19,4.54-4.01,7.24l3.5,4.78c.67.91,1.01,1.73,1.7,1.94,2.5.74,3.42-1.07,5.16.15l-.16-6.2c5.06-.66.3,10.94,7.05,9.19-.39,1.97-.65,4.21-1.67,6.09l-.88,1.61-23.67,3.92-26.09,5.6,28.81-1.28,21.9,1.79,2.66,21.59c.23,1.91.61,4.63,0,6.52-.9,2.75-4.75,3.45-6.85,4.52-.11-.6-.55-.77-.29-.96.27-.2.75-.49.46-.76-1.69-1.58-3.7-.88-5.42-1.5l-25.61-9.14c-2.01-.72-4.18-2.11-5.81.36,9.47,1.99,17.53,6.65,23.36,13.99,3.22,1.6,5.73,3.69,8.19,6.23,2.46,1.69,4.62,3.8,5.34,6.49,1.07-.18.92.5,1.05.83.15.38-.61.67-.62,1-.12,4.43-3.89,4.36-7.11,9.5-2.55,4.08-7.38,4.35-9.21,6.15l-.94.92c-.16.16.8.55.24.65-.67.12-1.28.23-1.51.37-.2.12.32.78-.29.87-1.03.15-2.52-.07-3.24.26-.61.28-1.54,1.08-1.53,2.3.01,1.24,3.21,1.69,4.07.81-.22,1.01.07,1.8.09,2.48l-6.88-.51c-3.33-.25-6.05-1.19-8.14-3.56l1.22-1.88c.25-.38-.05-1.81-.78-2.58l-7.92-8.31-1.79-6.55c1.07-.94.71-1.26-.44-1.1.06,2.16-1.07,5.3.01,7.63,2.52,5.41,2.99,11.11,2.18,16.92l-13.46,1.33c-2.91.29-5.41-.37-7.43-2.26l-.56-.52c.46-.08,1.32-.85.89-.92-.9-.15-.17-.72-.3-1.29-2.17-9.59-2.64-19.05-3.8-28.66l-2.19-18.19c-.03-.27-.5-.91-.37-1.27.06-.16-.25-.18-.89-.27.05,8.57-.38,17.06-1.41,25.88l-2.92,25.11c-2.05-1.12-1.73-2.64-2.66-3.77-2.42-1.3-5.35-.57-7.72.19-.82-1.15-2.95-.7-3.79-1.24l-7.41-4.74c-1.04-.66-1.4-2.15-1.42-3.1-.05-2.5,5.64-4.26,5.39-8.93l-4.61,2.69c-2.5,1.46-5.16,2.36-7.42,4.49,1.1,1.73,2.39,6.23.43,7.73-2.2,1.69-4.11-1.83-4.14-3.67-.04-2.86-4.05-4.49-6.8-4.27-2.19-1.72-4.47-2.69-7.41-2.81,2.92-5.58,6.84-9.71,11.07-13.92l9.26-9.22c-4.84,1.85-8.54,5.02-12.9,7.76l-13.73,8.66c-2.37-3.34-2.85-6.15-7.7-5.29-.62-.03-1.45-1.19-2.31-.96-.3-2.98.95-6.63,3.72-8.23,2.3-1.71,4.51-3.28,7.48-2.66l9.81-4.84-3.17-6.89c-.54-1.17-2.95-1.86-4.17-1.27-1.03.5-3.22,1.58-2.74,3.27.24.84,2.09,1.72,1.17,2.66-1.77,1.82-4.46,1.82-7,1.48-3.12-5.27-4.99-4.03-6.2-5.44-1.11-1.28-.17-2.47-1.16-4.71-.95-.19-2.17.03-3.09-.52l.47-8.25c5.41-1.54,11-1.66,16.65-2.11l13.76-1.11,23.43-2.65c.72-.08,1.55-.2,1.17-1.12l-34.67-.55-10.78-.52-6.8-1.66c.07.32.09.89.3,1.07.4.35-.32.87-.61,1.11-.78-.59-2.15-1.21-2.35-2.59l-1.83-12.66c-.25-1.72.79-3.24,1.79-4.12.41-.03,1.02.46.89.93-.09.34-.92.63-.78,1.42l24.02-6.49,13.85-1.37,22.02-.39-2.6-11.6c1.4-.26,3.69-1.85,4.49-2.9.89-1.16-.7-2.67-.56-3.76l1.42-10.88c2.44.37,3.7.45,5.42.41l.57,3.78,1.87,20.67c.26,2.86-.56,5.85,1.49,8.37.13-9.58.68-18.19,1.32-27.3l1.78-21.14c-.18-.34.15-.71.44-.92.45-.32.07-.8-.36-1.29.1.71-.38.67-.55.8.16-.14.65-.09.55-.8l26.18-2.61c2.68-.27,3.68,4.3,4.17,6.14Z"/>
|
||||||
|
<path class="cls-2" d="M70.64,293.55c.93.55,2.14.33,3.09.52.99,2.25.05,3.43,1.16,4.71,1.21,1.41,3.09.17,6.2,5.44,2.54.34,5.22.34,7-1.48.92-.95-.93-1.82-1.17-2.66-.48-1.69,1.71-2.77,2.74-3.27,1.22-.6,3.63.1,4.17,1.27l3.17,6.89-9.81,4.84c-2.97-.61-5.17.95-7.48,2.66-2.77,1.6-4.02,5.25-3.72,8.23.86-.23,1.7.94,2.31.96,4.85-.86,5.33,1.94,7.7,5.29l13.73-8.66c4.36-2.75,8.06-5.91,12.9-7.76l-9.26,9.22c-4.23,4.21-8.15,8.34-11.07,13.92,2.94.13,5.21,1.09,7.41,2.81,2.75-.22,6.76,1.41,6.8,4.27.03,1.84,1.94,5.36,4.14,3.67,1.95-1.5.67-6-.43-7.73,2.26-2.13,4.92-3.03,7.42-4.49l4.61-2.69c.25,4.67-5.44,6.43-5.39,8.93.02.95.39,2.43,1.42,3.1l7.41,4.74c.84.54,2.97.08,3.79,1.24,2.37-.76,5.3-1.48,7.72-.19.94,1.13.61,2.65,2.66,3.77l2.92-25.11c1.03-8.82,1.46-17.32,1.41-25.88.64.09.95.11.89.27-.13.37.34,1,.37,1.27l2.19,18.19c1.16,9.61,1.63,19.07,3.8,28.66.13.57-.6,1.14.3,1.29.43.07-.43.84-.89.92l.56.52-.56-.52c-1-.94-2.21-2.22-3.88-2.29-1.16,1.06-1.06,3.19-2.51,4-2.11,1.17-23.76,4.92-29.64,4.22l-26.91-3.21c-4.28-.51-8.3-1.54-12.64-.83-1.01.17-1.84.08-2.48-.35-1.08-.72-2.51-11.79-5.03-19.97-.63-2.06-.71-4.42-.42-6.58l2.23-17.05,1.07-15.1Z"/>
|
||||||
|
<path class="cls-1" d="M138.74,206.71c-.44,1.03-1.52,1.13-2.79.95l2.89,11.57.5,4.51c-1.72.04-2.98-.04-5.42-.41l-1.42,10.88c-.14,1.09,1.45,2.61.56,3.76-.8,1.04-3.08,2.63-4.49,2.9-2.41-10.77-4.78-21.47-4.96-32.84-.01-.81-.89-1-1.61-.47-.4.29-.56-.28-.69.82-.91,7.34-.87,14.61.45,22.21l1.81,10.39c.2,1.16-.14,1.74-.66,2.45-.66.89-1.51.05-2.58.15l-6.62.66c-11.9,1.2-23.41,4.28-34.02,9.94-1.49.79-2.59,1.69-4.04,1.19l-.45-.56-.41.43-.35.37.35-.37.41-.43,47.79-50.52c5.56-.03,11.1.34,15.74,2.42Z"/>
|
||||||
|
<path class="cls-7" d="M219.43,260.9c-6.75,1.75-1.99-9.85-7.05-9.19l.16,6.2c-1.73-1.22-2.65.59-5.16-.15-.69-.2-1.03-1.02-1.7-1.94l-3.5-4.78c1.82-2.7,4.06-7.11,4.01-7.24-.92-2.28-4.34-.47-5.18-1.09-.68-.5-.45-2.03.23-2.68,4.5-4.32,10.41.05,10.61-6.97.07-2.46.77-4.86-.98-6.82l-6.17.17c-1.05.03-2.64-2.58-1.92-3.32.92-.95,1.73-2.91,1.05-3.8h-3.49c-1.81.02-3.18.6-4.6,1.67-2.03-1.33-4.15-2.04-6.42-1.88-.15-1.38-.29-1.92.19-3.24-1.91.44-3.55.07-5.82-.02l-7.62,10.14-7.89,16.39c-.62,1.07-.78.08-1.37-.7l3.61-10.39c1.27-3.66,2.3-6.46,1.9-10.36l2.45-11.42,2.01-.07c.39,1.47,1.68,1.42,2.71,3.07l2.72-3.52c1.28-1.66,2.13-2.36,4.21-2.02,4.76.8,11.51-5.31,15.84-3.2l5.09.33,6.04.66c1.5.16,3.24-.48,4.43,1.23.92,1.32.41,2.84.7,4.58l2.01,12.15c.75,4.53-.52,9.03-.78,12.69l-.41,5.63-.11,12.64c-.02,2.58.65,4.91.18,7.23Z"/>
|
||||||
|
<path class="cls-5" d="M194.84,352.09c-.02-.68-.3-1.47-.09-2.48-.86.88-4.06.43-4.07-.81,0-1.21.92-2.02,1.53-2.3.72-.33,2.21-.11,3.24-.26.61-.09.09-.75.29-.87.23-.14.84-.25,1.51-.37.55-.1-.4-.49-.24-.65l.94-.92c1.83-1.8,6.66-2.07,9.21-6.15,3.22-5.14,6.99-5.06,7.11-9.5,0-.33.77-.62.62-1-.13-.33.02-1.01-1.05-.83-.72-2.69-2.89-4.8-5.34-6.49-2.46-2.53-4.96-4.63-8.19-6.23-5.84-7.33-13.89-12-23.36-13.99,1.63-2.47,3.8-1.08,5.81-.36l25.61,9.14c1.72.61,3.72-.09,5.42,1.5.29.27-.19.56-.46.76-.25.19.18.36.29.96l-2.06,1.95c1.07.86,1.21,1.79,2.05,2.05,1.4.43,2.37,1.45,3.11,2.99.49,1.04-.89,2.78-.4,4.19l3.22,9.28-1.02,21.79-10.21-.23-13.46-1.16Z"/>
|
||||||
|
<path class="cls-3" d="M128.56,240.87l2.6,11.6-22.02.39-13.85,1.37-24.02,6.49c-.14-.79.69-1.08.78-1.42.12-.46-.48-.95-.89-.93l3.19-2.78.1.02.35-.37.41-.43.45.56c1.45.49,2.55-.4,4.04-1.19,10.61-5.66,22.12-8.74,34.02-9.94l6.62-.66c1.07-.11,1.92.74,2.58-.15.52-.71.86-1.29.66-2.45l-1.81-10.39c-1.32-7.6-1.36-14.87-.45-22.21.14-1.1.29-.53.69-.82.72-.53,1.6-.34,1.61.47.18,11.37,2.55,22.07,4.96,32.84Z"/>
|
||||||
|
<path class="cls-8" d="M216.88,268.6l-1.26,4.05c1.5,1.91,1.96,3.93,2.21,5.96l-21.9-1.79-28.81,1.28,26.09-5.6,23.67-3.92Z"/>
|
||||||
|
<path class="cls-8" d="M73.54,277.75c.29-.24,1.01-.76.61-1.11-.21-.18-.23-.75-.3-1.07l6.8,1.66,10.78.52,34.67.55c.38.92-.45,1.04-1.17,1.12l-23.43,2.65-13.76,1.11c-5.64.46-11.23.57-16.65,2.11.22-3.96,6.26-5.09,2.43-7.55Z"/>
|
||||||
|
<path class="cls-10" d="M145.9,206.7c.16-.14.65-.09.55-.8.43.49.82.97.36,1.29-.29.21-.61.58-.44.92l-1.78,21.14c-.64,9.11-1.2,17.72-1.32,27.3-2.05-2.51-1.23-5.51-1.49-8.37l-1.87-20.67-.57-3.78-.5-4.51-2.89-11.57c1.27.18,2.35.08,2.79-.95,1.34.6,2.38,2.57,3.92,2.39s2.25-1.55,3.24-2.4Z"/>
|
||||||
|
<path class="cls-4" d="M179.83,348.02c-3.45-2.21-1.4,3.53-7.52,4.14.81-5.81.34-11.5-2.18-16.92-1.09-2.33.04-5.47-.01-7.63,1.15-.16,1.51.16.44,1.1l1.79,6.55,7.92,8.31c.73.77,1.03,2.2.78,2.58l-1.22,1.88Z"/>
|
||||||
|
<path class="cls-6" d="M176.79,209.43c-.49-1.84-1.49-6.41-4.17-6.14l-26.18,2.61c.1.71-.38.67-.55.8-.99.85-1.73,2.22-3.24,2.4s-2.58-1.79-3.92-2.39c-4.64-2.08-10.18-2.45-15.74-2.42l-47.79,50.52-.41.43-.45.35-3.19,2.78c-1,.87-2.04,2.4-1.79,4.12l1.83,12.66c.2,1.38,1.57,2.01,2.35,2.59,3.83,2.46-2.21,3.6-2.43,7.55l-.47,8.25-1.07,15.1-2.23,17.05c-.28,2.16-.21,4.53.42,6.58,2.52,8.18,3.95,19.25,5.03,19.97.64.42,1.47.51,2.48.35,4.34-.71,8.36.32,12.64.83l26.91,3.21c5.88.7,27.53-3.05,29.64-4.22,1.45-.81,1.35-2.94,2.51-4,1.67.07,2.88,1.35,3.88,2.29l.56.52c2.02,1.89,4.52,2.55,7.43,2.26l13.46-1.33c6.12-.6,4.07-6.35,7.52-4.14,2.09,2.37,4.82,3.31,8.14,3.56l6.88.51,13.46,1.16,10.21.23,1.02-21.79-3.22-9.28c-.49-1.42.9-3.16.4-4.19-.73-1.54-1.71-2.56-3.11-2.99"/>
|
||||||
|
<path class="cls-6" d="M121.3,208.37c-.91,7.34-.87,14.61.45,22.21l1.81,10.39c.2,1.16-.14,1.74-.66,2.45-.66.89-1.51.05-2.58.15l-6.62.66c-11.9,1.2-23.41,4.28-34.02,9.94-1.49.79-2.59,1.69-4.04,1.19-.41-.14-.87-.19-1.2.24"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 8.8 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);
|
||||||
@@ -200,5 +314,12 @@ var RoleSelect = (function () {
|
|||||||
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);
|
||||||
|
|||||||
760
src/apps/epic/static/apps/epic/sig-select.js
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
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, readyUrl, userRole, userPolarity;
|
||||||
|
|
||||||
|
var _isReady = false;
|
||||||
|
var _takeSigBtn = null;
|
||||||
|
var _glowTimer = null;
|
||||||
|
var _glowPeak = false;
|
||||||
|
var _countdownTimer = null;
|
||||||
|
var _countdownSecondsLeft = 0;
|
||||||
|
|
||||||
|
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');
|
||||||
|
_showTakeSigBtn();
|
||||||
|
}
|
||||||
|
// 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');
|
||||||
|
_hideTakeSigBtn();
|
||||||
|
}
|
||||||
|
// 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Reposition floats after resize ────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Both reserved (thumbs-up) and hover (hand-pointer) floats are stamped with
|
||||||
|
// fixed pixel coords at placement time. When the viewport changes size the
|
||||||
|
// cards reflow but the icons stay put. Re-measure from the card's current
|
||||||
|
// bounding rect and update left/top in-place.
|
||||||
|
|
||||||
|
var _posClasses = ['--left', '--mid', '--right'];
|
||||||
|
var _xFractions = [0.15, 0.5, 0.85];
|
||||||
|
|
||||||
|
function _repositionFloats() {
|
||||||
|
if (!deckGrid) return;
|
||||||
|
var roles = POLARITY_ROLES[userPolarity] || [];
|
||||||
|
|
||||||
|
Object.keys(_reservedFloats).forEach(function (role) {
|
||||||
|
var cardEl = deckGrid.querySelector('.sig-card[data-reserved-by="' + role + '"]');
|
||||||
|
if (!cardEl) return;
|
||||||
|
var rect = cardEl.getBoundingClientRect();
|
||||||
|
var idx = roles.indexOf(role);
|
||||||
|
var fc = _reservedFloats[role];
|
||||||
|
fc.style.left = (rect.left + rect.width * _xFractions[idx < 0 ? 1 : idx]) + 'px';
|
||||||
|
fc.style.top = rect.bottom + 'px';
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.keys(_floatingCursors).forEach(function (key) {
|
||||||
|
var posClass = _posClasses.find(function (p) {
|
||||||
|
return key.slice(-p.length) === p;
|
||||||
|
});
|
||||||
|
if (!posClass) return;
|
||||||
|
var cardId = key.slice(0, key.length - posClass.length);
|
||||||
|
var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]');
|
||||||
|
if (!cardEl) return;
|
||||||
|
var rect = cardEl.getBoundingClientRect();
|
||||||
|
var idx = _posClasses.indexOf(posClass);
|
||||||
|
var fc = _floatingCursors[key];
|
||||||
|
fc.style.left = (rect.left + rect.width * _xFractions[idx]) + 'px';
|
||||||
|
fc.style.top = rect.bottom + 'px';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TAKE SIG / WAIT NVM button ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function _onTakeSigClick() {
|
||||||
|
if (_isReady) {
|
||||||
|
var body = 'action=unready';
|
||||||
|
if (_countdownTimer !== null) {
|
||||||
|
body += '&seconds_remaining=' + _countdownSecondsLeft;
|
||||||
|
}
|
||||||
|
fetch(readyUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||||
|
body: body,
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
_isReady = false;
|
||||||
|
if (_countdownTimer !== null) {
|
||||||
|
clearInterval(_countdownTimer);
|
||||||
|
_countdownTimer = null;
|
||||||
|
if (_takeSigBtn) _takeSigBtn.style.fontSize = '';
|
||||||
|
}
|
||||||
|
if (_takeSigBtn) _takeSigBtn.textContent = 'TAKE SIG';
|
||||||
|
_stopWaitNoGlow();
|
||||||
|
_stopCountdownGlow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fetch(readyUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||||
|
body: 'action=ready',
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.ok) {
|
||||||
|
_isReady = true;
|
||||||
|
// countdown_start WS may arrive before this response for the
|
||||||
|
// gamer who triggered the countdown — don't clobber the numeral.
|
||||||
|
if (_countdownTimer === null) {
|
||||||
|
if (_takeSigBtn) _takeSigBtn.textContent = 'WAIT NVM';
|
||||||
|
_startWaitNoGlow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showTakeSigBtn() {
|
||||||
|
if (_takeSigBtn || !stage) return;
|
||||||
|
_takeSigBtn = document.createElement('button');
|
||||||
|
_takeSigBtn.id = 'id_take_sig_btn';
|
||||||
|
_takeSigBtn.className = 'btn btn-primary sig-take-sig-btn';
|
||||||
|
_takeSigBtn.type = 'button';
|
||||||
|
_takeSigBtn.textContent = 'TAKE SIG';
|
||||||
|
_takeSigBtn.addEventListener('click', _onTakeSigClick);
|
||||||
|
stage.appendChild(_takeSigBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startWaitNoGlow() {
|
||||||
|
if (_glowTimer !== null) return;
|
||||||
|
_glowPeak = false;
|
||||||
|
_glowTimer = setInterval(function () {
|
||||||
|
if (!_takeSigBtn) { _stopWaitNoGlow(); return; }
|
||||||
|
_glowPeak = !_glowPeak;
|
||||||
|
if (_glowPeak) {
|
||||||
|
_takeSigBtn.classList.add('btn-cancel');
|
||||||
|
_takeSigBtn.style.boxShadow =
|
||||||
|
'0 0 0.8rem 0.2rem rgba(var(--terOr), 0.75), ' +
|
||||||
|
'0 0 2rem 0.4rem rgba(var(--terOr), 0.35)';
|
||||||
|
} else {
|
||||||
|
_takeSigBtn.classList.remove('btn-cancel');
|
||||||
|
_takeSigBtn.style.boxShadow = '';
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopWaitNoGlow() {
|
||||||
|
if (_glowTimer !== null) { clearInterval(_glowTimer); _glowTimer = null; }
|
||||||
|
if (_takeSigBtn) {
|
||||||
|
_takeSigBtn.classList.remove('btn-cancel');
|
||||||
|
_takeSigBtn.style.boxShadow = '';
|
||||||
|
}
|
||||||
|
_glowPeak = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _startCountdownGlow() {
|
||||||
|
if (_glowTimer !== null) return;
|
||||||
|
_glowPeak = false;
|
||||||
|
_glowTimer = setInterval(function () {
|
||||||
|
if (!_takeSigBtn) { _stopCountdownGlow(); return; }
|
||||||
|
_glowPeak = !_glowPeak;
|
||||||
|
if (_glowPeak) {
|
||||||
|
_takeSigBtn.classList.add('btn-danger');
|
||||||
|
_takeSigBtn.style.boxShadow =
|
||||||
|
'0 0 0.8rem 0.2rem rgba(var(--terRd), 0.75), ' +
|
||||||
|
'0 0 2rem 0.4rem rgba(var(--terRd), 0.35)';
|
||||||
|
} else {
|
||||||
|
_takeSigBtn.classList.remove('btn-danger');
|
||||||
|
_takeSigBtn.style.boxShadow = '';
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stopCountdownGlow() {
|
||||||
|
if (_glowTimer !== null) { clearInterval(_glowTimer); _glowTimer = null; }
|
||||||
|
if (_takeSigBtn) {
|
||||||
|
_takeSigBtn.classList.remove('btn-danger');
|
||||||
|
_takeSigBtn.style.boxShadow = '';
|
||||||
|
}
|
||||||
|
_glowPeak = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hideTakeSigBtn() {
|
||||||
|
if (!_takeSigBtn) return;
|
||||||
|
_stopWaitNoGlow();
|
||||||
|
_takeSigBtn.removeEventListener('click', _onTakeSigClick);
|
||||||
|
_takeSigBtn.remove();
|
||||||
|
_takeSigBtn = null;
|
||||||
|
_isReady = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Polarity countdown ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _showCountdown(seconds) {
|
||||||
|
_countdownSecondsLeft = seconds;
|
||||||
|
_stopWaitNoGlow();
|
||||||
|
if (_takeSigBtn) {
|
||||||
|
_takeSigBtn.textContent = _countdownSecondsLeft;
|
||||||
|
_takeSigBtn.style.fontSize = '2em';
|
||||||
|
}
|
||||||
|
_startCountdownGlow();
|
||||||
|
if (_countdownTimer !== null) clearInterval(_countdownTimer);
|
||||||
|
_countdownTimer = setInterval(function () {
|
||||||
|
_countdownSecondsLeft -= 1;
|
||||||
|
if (_takeSigBtn) _takeSigBtn.textContent = _countdownSecondsLeft;
|
||||||
|
if (_countdownSecondsLeft <= 0) {
|
||||||
|
clearInterval(_countdownTimer);
|
||||||
|
_countdownTimer = null;
|
||||||
|
_stopCountdownGlow(); // server drives the transition via Celery task
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hideCountdown() {
|
||||||
|
if (_countdownTimer !== null) {
|
||||||
|
clearInterval(_countdownTimer);
|
||||||
|
_countdownTimer = null;
|
||||||
|
}
|
||||||
|
_stopCountdownGlow();
|
||||||
|
if (_takeSigBtn) {
|
||||||
|
_takeSigBtn.style.fontSize = '';
|
||||||
|
if (_isReady) {
|
||||||
|
// Countdown cancelled by another gamer — restore WAIT NVM state
|
||||||
|
_takeSigBtn.textContent = 'WAIT NVM';
|
||||||
|
_startWaitNoGlow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Overlay dismiss + waiting message ─────────────────────────────────
|
||||||
|
|
||||||
|
function _dismissSigOverlay() {
|
||||||
|
_hideCountdown();
|
||||||
|
_hideTakeSigBtn();
|
||||||
|
var backdrop = document.querySelector('.sig-backdrop');
|
||||||
|
if (backdrop) backdrop.remove();
|
||||||
|
if (overlay) { overlay.remove(); overlay = null; }
|
||||||
|
// Remove all floating cursors (hover + thumbs-up) from the portal
|
||||||
|
Object.keys(_reservedFloats).forEach(function (role) {
|
||||||
|
_reservedFloats[role].remove();
|
||||||
|
});
|
||||||
|
_reservedFloats = {};
|
||||||
|
Object.keys(_floatingCursors).forEach(function (key) {
|
||||||
|
_floatingCursors[key].remove();
|
||||||
|
});
|
||||||
|
_floatingCursors = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showWaitingMsg(pendingPolarity) {
|
||||||
|
if (document.getElementById('id_hex_waiting_msg')) return;
|
||||||
|
var msg = document.createElement('p');
|
||||||
|
msg.id = 'id_hex_waiting_msg';
|
||||||
|
msg.textContent = pendingPolarity === 'gravity'
|
||||||
|
? 'Gravity settling . . .'
|
||||||
|
: 'Levity appraising . . .';
|
||||||
|
var center = document.querySelector('.table-center');
|
||||||
|
if (center) center.appendChild(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('room:countdown_start', function (e) {
|
||||||
|
if (!overlay) return;
|
||||||
|
_showCountdown(e.detail.seconds);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('room:countdown_cancel', function (e) {
|
||||||
|
_hideCountdown();
|
||||||
|
_countdownSecondsLeft = e.detail.seconds_remaining;
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('room:polarity_room_done', function (e) {
|
||||||
|
if (!overlay) return;
|
||||||
|
if (e.detail.polarity !== userPolarity) return;
|
||||||
|
var pendingPolarity = userPolarity === 'levity' ? 'gravity' : 'levity';
|
||||||
|
_dismissSigOverlay();
|
||||||
|
_showWaitingMsg(pendingPolarity);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('room:pick_sky_available', function () {
|
||||||
|
var msg = document.getElementById('id_hex_waiting_msg');
|
||||||
|
if (msg) msg.remove();
|
||||||
|
var btn = document.getElementById('id_pick_sky_btn');
|
||||||
|
if (btn) btn.style.display = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('resize:end', _repositionFloats);
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
readyUrl = overlay.dataset.readyUrl;
|
||||||
|
|
||||||
|
userRole = overlay.dataset.userRole;
|
||||||
|
userPolarity= overlay.dataset.polarity;
|
||||||
|
|
||||||
|
// PICK SKY btn is rendered hidden during SIG_SELECT; reveal on pick_sky_available
|
||||||
|
var pickSkyBtn = document.getElementById('id_pick_sky_btn');
|
||||||
|
if (pickSkyBtn) {
|
||||||
|
pickSkyBtn.addEventListener('click', function () {
|
||||||
|
if (typeof Tray !== 'undefined') Tray.open();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
// Restore WAIT NVM state if gamer was already ready before page load
|
||||||
|
if (overlay.dataset.ready === 'true' && _takeSigBtn) {
|
||||||
|
_isReady = true;
|
||||||
|
_takeSigBtn.textContent = 'WAIT NVM';
|
||||||
|
_startWaitNoGlow();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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;
|
||||||
|
_isReady = false;
|
||||||
|
_stopWaitNoGlow();
|
||||||
|
_hideTakeSigBtn();
|
||||||
|
_hideCountdown();
|
||||||
|
_countdownSecondsLeft = 0;
|
||||||
|
init();
|
||||||
|
},
|
||||||
|
_setFrozen: function (v) { _stageFrozen = v; },
|
||||||
|
_setReservedCardId: function (id) { _reservedCardId = id; },
|
||||||
|
};
|
||||||
|
}());
|
||||||
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; },
|
||||||
|
};
|
||||||
|
}());
|
||||||
95
src/apps/epic/tasks.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
Countdown scheduler for the polarity-room TAKE SIG gate.
|
||||||
|
|
||||||
|
Uses threading.Timer so no separate Celery worker is needed in development.
|
||||||
|
Single-process only — swap for a Celery task if production uses multiple
|
||||||
|
web workers (gunicorn -w N with N > 1).
|
||||||
|
"""
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
_LEVITY_ROLES = {'PC', 'NC', 'SC'}
|
||||||
|
_GRAVITY_ROLES = {'BC', 'EC', 'AC'}
|
||||||
|
|
||||||
|
# In-process registry of pending timers: "{room_id}_{polarity}" → Timer
|
||||||
|
_timers = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _cache_key(room_id, polarity):
|
||||||
|
return f'sig_countdown_{room_id}_{polarity}'
|
||||||
|
|
||||||
|
|
||||||
|
def _group_send(room_id, msg):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(f'room_{room_id}', msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _fire(room_id, polarity, token):
|
||||||
|
"""Callback run by threading.Timer after the countdown expires."""
|
||||||
|
# Token guard: if cancelled or superseded, cache entry will differ
|
||||||
|
if cache.get(_cache_key(room_id, polarity)) != token:
|
||||||
|
return
|
||||||
|
|
||||||
|
from apps.epic.models import Room, SigReservation
|
||||||
|
|
||||||
|
try:
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
except Room.DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
|
if room.table_status != Room.SIG_SELECT:
|
||||||
|
return
|
||||||
|
|
||||||
|
polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
|
||||||
|
|
||||||
|
# Idempotency: seats already assigned
|
||||||
|
if not room.table_seats.filter(role__in=polarity_roles, significator__isnull=True).exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Safety: all three must still be ready
|
||||||
|
ready_reservations = list(
|
||||||
|
SigReservation.objects.filter(room=room, polarity=polarity, ready=True)
|
||||||
|
.select_related('seat', 'card')
|
||||||
|
)
|
||||||
|
if len(ready_reservations) < 3:
|
||||||
|
return
|
||||||
|
|
||||||
|
for res in ready_reservations:
|
||||||
|
if res.seat:
|
||||||
|
res.seat.significator = res.card
|
||||||
|
res.seat.save(update_fields=['significator'])
|
||||||
|
|
||||||
|
SigReservation.objects.filter(room=room, polarity=polarity).update(countdown_remaining=None)
|
||||||
|
|
||||||
|
_group_send(room_id, {'type': 'polarity_room_done', 'polarity': polarity})
|
||||||
|
|
||||||
|
if not room.table_seats.filter(significator__isnull=True).exists():
|
||||||
|
Room.objects.filter(id=room_id).update(table_status=Room.SKY_SELECT)
|
||||||
|
_group_send(room_id, {'type': 'pick_sky_available'})
|
||||||
|
|
||||||
|
cache.delete(_cache_key(room_id, polarity))
|
||||||
|
_timers.pop(f'{room_id}_{polarity}', None)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_polarity_confirm(room_id, polarity, seconds):
|
||||||
|
"""Schedule a polarity confirm `seconds` seconds from now. Cancels any prior timer."""
|
||||||
|
cancel_polarity_confirm(room_id, polarity)
|
||||||
|
|
||||||
|
token = str(uuid.uuid4())
|
||||||
|
cache.set(_cache_key(room_id, polarity), token, timeout=int(seconds) + 60)
|
||||||
|
|
||||||
|
timer = threading.Timer(seconds, _fire, args=[str(room_id), polarity, token])
|
||||||
|
timer.daemon = True
|
||||||
|
timer.start()
|
||||||
|
_timers[f'{room_id}_{polarity}'] = timer
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_polarity_confirm(room_id, polarity):
|
||||||
|
"""Cancel any pending confirm for this room + polarity."""
|
||||||
|
timer = _timers.pop(f'{room_id}_{polarity}', None)
|
||||||
|
if timer:
|
||||||
|
timer.cancel()
|
||||||
|
cache.delete(_cache_key(room_id, polarity))
|
||||||
@@ -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,10 +4,13 @@ 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 (
|
from apps.epic.models import (
|
||||||
DeckVariant, GateSlot, Room, RoomInvite, TableSeat, TarotCard,
|
DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
||||||
debit_token, select_token, sig_deck_cards, sig_seat_order, active_sig_seat,
|
debit_token, select_token, sig_deck_cards, levity_sig_cards, gravity_sig_cards,
|
||||||
|
sig_seat_order, active_sig_seat,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -266,16 +269,16 @@ class SigDeckCompositionTest(TestCase):
|
|||||||
cards = sig_deck_cards(self.room)
|
cards = sig_deck_cards(self.room)
|
||||||
self.assertEqual(len(cards), 36)
|
self.assertEqual(len(cards), 36)
|
||||||
|
|
||||||
def test_sc_ac_contribute_court_cards_of_swords_and_cups(self):
|
def test_sc_ac_contribute_court_cards_of_blades_and_grails(self):
|
||||||
cards = sig_deck_cards(self.room)
|
cards = sig_deck_cards(self.room)
|
||||||
sc_ac = [c for c in cards if c.suit in ("SWORDS", "CUPS")]
|
sc_ac = [c for c in cards if c.suit in ("BLADES", "GRAILS")]
|
||||||
# M/J/Q/K × 2 suits × 2 roles = 16
|
# M/J/Q/K × 2 suits × 2 roles = 16
|
||||||
self.assertEqual(len(sc_ac), 16)
|
self.assertEqual(len(sc_ac), 16)
|
||||||
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in sc_ac))
|
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in sc_ac))
|
||||||
|
|
||||||
def test_pc_bc_contribute_court_cards_of_wands_and_pentacles(self):
|
def test_pc_bc_contribute_court_cards_of_brands_and_crowns(self):
|
||||||
cards = sig_deck_cards(self.room)
|
cards = sig_deck_cards(self.room)
|
||||||
pc_bc = [c for c in cards if c.suit in ("WANDS", "PENTACLES")]
|
pc_bc = [c for c in cards if c.suit in ("BRANDS", "CROWNS")]
|
||||||
self.assertEqual(len(pc_bc), 16)
|
self.assertEqual(len(pc_bc), 16)
|
||||||
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in pc_bc))
|
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in pc_bc))
|
||||||
|
|
||||||
@@ -339,7 +342,7 @@ class SigCardFieldTest(TestCase):
|
|||||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||||
)
|
)
|
||||||
self.card = TarotCard.objects.get(
|
self.card = TarotCard.objects.get(
|
||||||
deck_variant=earthman, arcana="MINOR", suit="WANDS", number=11,
|
deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=11,
|
||||||
)
|
)
|
||||||
owner = User.objects.create(email="owner@test.io")
|
owner = User.objects.create(email="owner@test.io")
|
||||||
room = Room.objects.create(name="Field Test", owner=owner)
|
room = Room.objects.create(name="Field Test", owner=owner)
|
||||||
@@ -360,3 +363,233 @@ class SigCardFieldTest(TestCase):
|
|||||||
self.card.delete()
|
self.card.delete()
|
||||||
self.seat.refresh_from_db()
|
self.seat.refresh_from_db()
|
||||||
self.assertIsNone(self.seat.significator)
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
# ── SigReservation ready gate ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SigReservationReadyGateTest(TestCase):
|
||||||
|
"""SigReservation.ready and countdown_remaining fields."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||||
|
owner = User.objects.create(email="owner@test.io")
|
||||||
|
room = Room.objects.create(name="R", owner=owner)
|
||||||
|
card = TarotCard.objects.get(
|
||||||
|
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||||||
|
)
|
||||||
|
self.res = SigReservation.objects.create(
|
||||||
|
room=room, gamer=owner, card=card, role="PC", polarity="levity"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ready_defaults_to_false(self):
|
||||||
|
self.assertFalse(self.res.ready)
|
||||||
|
|
||||||
|
def test_countdown_remaining_defaults_to_none(self):
|
||||||
|
self.assertIsNone(self.res.countdown_remaining)
|
||||||
|
|
||||||
|
def test_ready_can_be_set_true(self):
|
||||||
|
self.res.ready = True
|
||||||
|
self.res.save()
|
||||||
|
self.res.refresh_from_db()
|
||||||
|
self.assertTrue(self.res.ready)
|
||||||
|
|
||||||
|
def test_countdown_remaining_can_be_saved(self):
|
||||||
|
self.res.countdown_remaining = 7
|
||||||
|
self.res.save()
|
||||||
|
self.res.refresh_from_db()
|
||||||
|
self.assertEqual(self.res.countdown_remaining, 7)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Room SKY_SELECT status ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class RoomSkySelectStatusTest(TestCase):
|
||||||
|
"""Room.SKY_SELECT constant and sig_select_started_at field."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
owner = User.objects.create(email="owner@test.io")
|
||||||
|
self.room = Room.objects.create(name="R", owner=owner)
|
||||||
|
|
||||||
|
def test_sky_select_constant_value(self):
|
||||||
|
self.assertEqual(Room.SKY_SELECT, "SKY_SELECT")
|
||||||
|
|
||||||
|
def test_sky_select_is_valid_table_status_choice(self):
|
||||||
|
choices = [c[0] for c in Room.TABLE_STATUS_CHOICES]
|
||||||
|
self.assertIn(Room.SKY_SELECT, choices)
|
||||||
|
|
||||||
|
def test_sig_select_started_at_defaults_to_none(self):
|
||||||
|
self.assertIsNone(self.room.sig_select_started_at)
|
||||||
|
|
||||||
|
def test_sig_select_started_at_can_be_set(self):
|
||||||
|
from django.utils import timezone
|
||||||
|
now = timezone.now()
|
||||||
|
self.room.sig_select_started_at = now
|
||||||
|
self.room.save()
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertIsNotNone(self.room.sig_select_started_at)
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
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 (
|
from apps.epic.models import (
|
||||||
DeckVariant, GateSlot, Room, RoomInvite, TableSeat, TarotCard,
|
DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -367,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):
|
||||||
@@ -495,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):
|
||||||
@@ -556,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)
|
||||||
@@ -568,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:
|
||||||
@@ -578,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"},
|
||||||
@@ -633,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):
|
||||||
@@ -648,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):
|
||||||
@@ -804,7 +926,7 @@ def _full_sig_setUp(test_case, role_order=None):
|
|||||||
room.table_status = Room.SIG_SELECT
|
room.table_status = Room.SIG_SELECT
|
||||||
room.save()
|
room.save()
|
||||||
card_in_deck = TarotCard.objects.get(
|
card_in_deck = TarotCard.objects.get(
|
||||||
deck_variant=earthman, arcana="MINOR", suit="WANDS", number=11
|
deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||||||
)
|
)
|
||||||
test_case.client.force_login(founder)
|
test_case.client.force_login(founder)
|
||||||
return room, gamers, earthman, card_in_deck
|
return room, gamers, earthman, card_in_deck
|
||||||
@@ -815,15 +937,15 @@ class SigSelectRenderingTest(TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||||||
self.url = reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||||||
|
|
||||||
def test_sig_deck_element_present(self):
|
def test_sig_deck_element_present(self):
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertContains(response, "id_sig_deck")
|
self.assertContains(response, "id_sig_deck")
|
||||||
|
|
||||||
def test_sig_deck_contains_36_sig_cards(self):
|
def test_sig_deck_contains_18_sig_cards(self):
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertEqual(response.content.decode().count('class="sig-card"'), 36)
|
self.assertEqual(response.content.decode().count('data-card-id='), 18)
|
||||||
|
|
||||||
def test_seats_rendered_in_pc_nc_ec_sc_ac_bc_order(self):
|
def test_seats_rendered_in_pc_nc_ec_sc_ac_bc_order(self):
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
@@ -841,6 +963,32 @@ class SigSelectRenderingTest(TestCase):
|
|||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
self.assertNotContains(response, "id_sig_deck")
|
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):
|
class SelectSigCardViewTest(TestCase):
|
||||||
"""select_sig view — records choice, enforces turn order, rejects bad input."""
|
"""select_sig view — records choice, enforces turn order, rejects bad input."""
|
||||||
@@ -878,8 +1026,8 @@ class SelectSigCardViewTest(TestCase):
|
|||||||
def test_select_sig_card_not_in_deck_returns_400(self):
|
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)
|
# Create a pip card (number=5) — not in the sig deck (only court 11–14 + major 0–1)
|
||||||
other = TarotCard.objects.create(
|
other = TarotCard.objects.create(
|
||||||
deck_variant=self.earthman, arcana="MINOR", suit="WANDS", number=5,
|
deck_variant=self.earthman, arcana="MINOR", suit="BRANDS", number=5,
|
||||||
name="Five of Wands Test", slug="five-of-wands-test",
|
name="Five of Brands Test", slug="five-of-brands-test",
|
||||||
keywords_upright=[], keywords_reversed=[],
|
keywords_upright=[], keywords_reversed=[],
|
||||||
)
|
)
|
||||||
response = self._post(card_id=other.id)
|
response = self._post(card_id=other.id)
|
||||||
@@ -902,7 +1050,7 @@ class SelectSigCardViewTest(TestCase):
|
|||||||
def test_select_sig_notifies_ws(self):
|
def test_select_sig_notifies_ws(self):
|
||||||
with patch("apps.epic.views._notify_sig_selected") as mock_notify:
|
with patch("apps.epic.views._notify_sig_selected") as mock_notify:
|
||||||
self._post()
|
self._post()
|
||||||
mock_notify.assert_called_once_with(self.room.id)
|
mock_notify.assert_called_once()
|
||||||
|
|
||||||
def test_select_sig_requires_login(self):
|
def test_select_sig_requires_login(self):
|
||||||
self.client.logout()
|
self.client.logout()
|
||||||
@@ -915,7 +1063,7 @@ class SelectSigCardViewTest(TestCase):
|
|||||||
self.room.save()
|
self.room.save()
|
||||||
response = self._post()
|
response = self._post()
|
||||||
self.assertRedirects(
|
self.assertRedirects(
|
||||||
response, reverse("epic:gatekeeper", args=[self.room.id])
|
response, reverse("epic:room", args=[self.room.id])
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_select_sig_last_choice_does_not_advance_to_none(self):
|
def test_select_sig_last_choice_does_not_advance_to_none(self):
|
||||||
@@ -937,3 +1085,530 @@ class SelectSigCardViewTest(TestCase):
|
|||||||
).first()
|
).first()
|
||||||
response = self.client.post(self.url, data={"card_id": last_card.id})
|
response = self.client.post(self.url, data={"card_id": last_card.id})
|
||||||
self.assertIn(response.status_code, (200, 302))
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ── sig_ready view ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_levity_reservations(room, gamers, earthman, ready=False):
|
||||||
|
"""Create SigReservations for the three levity gamers (PC, NC, SC).
|
||||||
|
Returns the three reservations in PC→NC→SC order."""
|
||||||
|
cards = [
|
||||||
|
TarotCard.objects.get(deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=n)
|
||||||
|
for n in (11, 12, 13)
|
||||||
|
]
|
||||||
|
roles = ["PC", "NC", "SC"]
|
||||||
|
# gamers[0]=PC, gamers[1]=NC, gamers[3]=SC
|
||||||
|
gamer_indices = [0, 1, 3]
|
||||||
|
reservations = []
|
||||||
|
for gamer_idx, role, card in zip(gamer_indices, roles, cards):
|
||||||
|
seat = TableSeat.objects.get(room=room, role=role)
|
||||||
|
res = SigReservation.objects.create(
|
||||||
|
room=room, gamer=gamers[gamer_idx], card=card,
|
||||||
|
role=role, polarity="levity", seat=seat, ready=ready,
|
||||||
|
)
|
||||||
|
reservations.append(res)
|
||||||
|
return reservations
|
||||||
|
|
||||||
|
|
||||||
|
class SigReadyViewTest(TestCase):
|
||||||
|
"""sig_ready — toggle ready/unready for the polarity-room countdown."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||||||
|
self.reservations = _make_levity_reservations(self.room, self.gamers, self.earthman)
|
||||||
|
self.url = reverse("epic:sig_ready", kwargs={"room_id": self.room.id})
|
||||||
|
|
||||||
|
def _post(self, action="ready", seconds_remaining=None, client=None):
|
||||||
|
c = client or self.client
|
||||||
|
data = {"action": action}
|
||||||
|
if seconds_remaining is not None:
|
||||||
|
data["seconds_remaining"] = seconds_remaining
|
||||||
|
return c.post(self.url, data=data)
|
||||||
|
|
||||||
|
# ── guards ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sig_ready_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/accounts/login/", response.url)
|
||||||
|
|
||||||
|
def test_sig_ready_requires_seated_gamer(self):
|
||||||
|
outsider = User.objects.create(email="outsider@test.io")
|
||||||
|
outsider_client = self.client.__class__()
|
||||||
|
outsider_client.force_login(outsider)
|
||||||
|
response = self._post(client=outsider_client)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_sig_ready_wrong_phase_returns_400(self):
|
||||||
|
self.room.table_status = Room.ROLE_SELECT
|
||||||
|
self.room.save()
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_sig_ready_without_reservation_returns_400(self):
|
||||||
|
"""Can't go ready without an OK'd card."""
|
||||||
|
SigReservation.objects.filter(room=self.room, gamer=self.gamers[0]).delete()
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# ── happy-path ready ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sig_ready_sets_ready_true_on_reservation(self):
|
||||||
|
self._post(action="ready")
|
||||||
|
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
||||||
|
self.assertTrue(res.ready)
|
||||||
|
|
||||||
|
def test_sig_ready_returns_200(self):
|
||||||
|
response = self._post(action="ready")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# ── unready ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sig_unready_sets_ready_false(self):
|
||||||
|
self.reservations[0].ready = True
|
||||||
|
self.reservations[0].save()
|
||||||
|
self._post(action="unready")
|
||||||
|
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
||||||
|
self.assertFalse(res.ready)
|
||||||
|
|
||||||
|
def test_sig_unready_when_not_ready_is_harmless(self):
|
||||||
|
response = self._post(action="unready")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# ── countdown mechanics ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sig_ready_broadcasts_countdown_start_when_all_three_polarity_ready(self):
|
||||||
|
"""When all three levity gamers are ready, countdown_start broadcasts."""
|
||||||
|
# Make NC and SC ready first
|
||||||
|
for res in self.reservations[1:]:
|
||||||
|
res.ready = True
|
||||||
|
res.save()
|
||||||
|
# PC (founder) goes ready — triggers all-three condition
|
||||||
|
with patch("apps.epic.views._notify_countdown_start") as mock_notify:
|
||||||
|
self._post(action="ready")
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
args = mock_notify.call_args[0]
|
||||||
|
self.assertIn("levity", args) # polarity in call
|
||||||
|
|
||||||
|
def test_sig_ready_does_not_broadcast_countdown_when_only_two_ready(self):
|
||||||
|
self.reservations[1].ready = True
|
||||||
|
self.reservations[1].save()
|
||||||
|
with patch("apps.epic.views._notify_countdown_start") as mock_notify:
|
||||||
|
self._post(action="ready")
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
|
||||||
|
def test_sig_unready_saves_seconds_remaining_on_all_polarity_reservations(self):
|
||||||
|
for res in self.reservations:
|
||||||
|
res.ready = True
|
||||||
|
res.save()
|
||||||
|
self._post(action="unready", seconds_remaining=7)
|
||||||
|
for res in self.reservations:
|
||||||
|
res.refresh_from_db()
|
||||||
|
self.assertEqual(res.countdown_remaining, 7)
|
||||||
|
|
||||||
|
def test_sig_unready_broadcasts_countdown_cancel(self):
|
||||||
|
for res in self.reservations:
|
||||||
|
res.ready = True
|
||||||
|
res.save()
|
||||||
|
with patch("apps.epic.views._notify_countdown_cancel") as mock_notify:
|
||||||
|
self._post(action="unready", seconds_remaining=7)
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
|
||||||
|
def test_sig_ready_uses_saved_seconds_for_countdown_restart(self):
|
||||||
|
"""If countdown_remaining is saved (e.g. 7), countdown_start sends 7 not 12."""
|
||||||
|
for res in self.reservations:
|
||||||
|
res.ready = True
|
||||||
|
res.countdown_remaining = 7
|
||||||
|
res.save()
|
||||||
|
# One unreadied; now goes ready again — all 3 ready → start from 7
|
||||||
|
self.reservations[0].ready = False
|
||||||
|
self.reservations[0].save()
|
||||||
|
with patch("apps.epic.views._notify_countdown_start") as mock_notify:
|
||||||
|
self._post(action="ready")
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
args, kwargs = mock_notify.call_args
|
||||||
|
seconds_sent = kwargs.get("seconds") or args[1]
|
||||||
|
self.assertEqual(seconds_sent, 7)
|
||||||
|
|
||||||
|
|
||||||
|
# ── sig_confirm view ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _make_gravity_reservations(room, gamers, earthman, ready=False):
|
||||||
|
"""Create SigReservations for the three gravity gamers (EC, AC, BC)."""
|
||||||
|
cards = [
|
||||||
|
TarotCard.objects.get(deck_variant=earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
||||||
|
for n in (11, 12, 13)
|
||||||
|
]
|
||||||
|
roles = ["EC", "AC", "BC"]
|
||||||
|
# gamers[2]=EC, gamers[4]=AC, gamers[5]=BC
|
||||||
|
gamer_indices = [2, 4, 5]
|
||||||
|
reservations = []
|
||||||
|
for gamer_idx, role, card in zip(gamer_indices, roles, cards):
|
||||||
|
seat = TableSeat.objects.get(room=room, role=role)
|
||||||
|
res = SigReservation.objects.create(
|
||||||
|
room=room, gamer=gamers[gamer_idx], card=card,
|
||||||
|
role=role, polarity="gravity", seat=seat, ready=ready,
|
||||||
|
)
|
||||||
|
reservations.append(res)
|
||||||
|
return reservations
|
||||||
|
|
||||||
|
|
||||||
|
class SigConfirmViewTest(TestCase):
|
||||||
|
"""sig_confirm — finalize polarity group once countdown reaches zero."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||||||
|
# All three levity gamers are ready
|
||||||
|
self.lev_res = _make_levity_reservations(
|
||||||
|
self.room, self.gamers, self.earthman, ready=True
|
||||||
|
)
|
||||||
|
# founder (PC) is already logged in from _full_sig_setUp
|
||||||
|
self.url = reverse("epic:sig_confirm", kwargs={"room_id": self.room.id})
|
||||||
|
|
||||||
|
def _post(self, polarity="levity", client=None):
|
||||||
|
c = client or self.client
|
||||||
|
return c.post(self.url, data={"polarity": polarity})
|
||||||
|
|
||||||
|
# ── guards ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sig_confirm_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/accounts/login/", response.url)
|
||||||
|
|
||||||
|
def test_sig_confirm_requires_seated_gamer(self):
|
||||||
|
outsider = User.objects.create(email="outsider@test.io")
|
||||||
|
outsider_client = self.client.__class__()
|
||||||
|
outsider_client.force_login(outsider)
|
||||||
|
response = self._post(client=outsider_client)
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_sig_confirm_wrong_phase_returns_400(self):
|
||||||
|
self.room.table_status = Room.ROLE_SELECT
|
||||||
|
self.room.save()
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_sig_confirm_not_all_polarity_ready_returns_400(self):
|
||||||
|
"""If any of the three in the polarity group isn't ready, reject."""
|
||||||
|
self.lev_res[1].ready = False
|
||||||
|
self.lev_res[1].save()
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# ── happy-path ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sig_confirm_sets_significator_on_seats_from_reservations(self):
|
||||||
|
self._post()
|
||||||
|
for res in self.lev_res:
|
||||||
|
seat = TableSeat.objects.get(room=self.room, role=res.role)
|
||||||
|
self.assertEqual(seat.significator, res.card)
|
||||||
|
|
||||||
|
def test_sig_confirm_returns_200(self):
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_sig_confirm_broadcasts_polarity_room_done(self):
|
||||||
|
with patch("apps.epic.views._notify_polarity_room_done") as mock_notify:
|
||||||
|
self._post()
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
args = mock_notify.call_args[0]
|
||||||
|
self.assertIn("levity", args)
|
||||||
|
|
||||||
|
def test_sig_confirm_is_idempotent_if_significators_already_set(self):
|
||||||
|
"""Second call from another browser returns 200 without re-running logic."""
|
||||||
|
self._post()
|
||||||
|
response = self._post()
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# ── both polarities done ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sig_confirm_broadcasts_pick_sky_available_when_both_polarities_done(self):
|
||||||
|
"""After both levity and gravity confirm, pick_sky_available fires."""
|
||||||
|
# Pre-set gravity seats to already have significators (simulating earlier confirm)
|
||||||
|
grav_cards = [
|
||||||
|
TarotCard.objects.get(deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
||||||
|
for n in (11, 12, 13)
|
||||||
|
]
|
||||||
|
for role, card in zip(["EC", "AC", "BC"], grav_cards):
|
||||||
|
seat = TableSeat.objects.get(room=self.room, role=role)
|
||||||
|
seat.significator = card
|
||||||
|
seat.save()
|
||||||
|
with patch("apps.epic.views._notify_pick_sky_available") as mock_notify:
|
||||||
|
self._post(polarity="levity")
|
||||||
|
mock_notify.assert_called_once()
|
||||||
|
|
||||||
|
def test_sig_confirm_sets_room_to_sky_select_when_both_polarities_done(self):
|
||||||
|
grav_cards = [
|
||||||
|
TarotCard.objects.get(deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
||||||
|
for n in (11, 12, 13)
|
||||||
|
]
|
||||||
|
for role, card in zip(["EC", "AC", "BC"], grav_cards):
|
||||||
|
seat = TableSeat.objects.get(room=self.room, role=role)
|
||||||
|
seat.significator = card
|
||||||
|
seat.save()
|
||||||
|
self._post(polarity="levity")
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertEqual(self.room.table_status, Room.SKY_SELECT)
|
||||||
|
|
||||||
|
def test_sig_confirm_does_not_broadcast_pick_sky_available_when_only_one_polarity_done(self):
|
||||||
|
with patch("apps.epic.views._notify_pick_sky_available") as mock_notify:
|
||||||
|
self._post(polarity="levity")
|
||||||
|
mock_notify.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ── SKY_SELECT rendering ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PickSkyRenderingTest(TestCase):
|
||||||
|
"""Room page at SKY_SELECT renders PICK SKY btn and sig card in tray cell 2."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||||||
|
self.room.table_status = Room.SKY_SELECT
|
||||||
|
self.room.save()
|
||||||
|
self.sig_card = TarotCard.objects.get(
|
||||||
|
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||||||
|
)
|
||||||
|
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
||||||
|
pc_seat.significator = self.sig_card
|
||||||
|
pc_seat.save()
|
||||||
|
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||||||
|
|
||||||
|
def test_pick_sky_btn_present_in_sky_select_phase(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, "id_pick_sky_btn")
|
||||||
|
|
||||||
|
def test_tray_cell_2_contains_sig_card_icon_in_sky_select(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, "tray-sig-card")
|
||||||
|
|
||||||
|
def test_pick_sky_btn_hidden_during_sig_select(self):
|
||||||
|
# Rendered hidden (display:none) so JS can reveal it on pick_sky_available WS event
|
||||||
|
self.room.table_status = Room.SIG_SELECT
|
||||||
|
self.room.save()
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertContains(response, 'id="id_pick_sky_btn"')
|
||||||
|
self.assertContains(response, 'style="display:none"')
|
||||||
|
|||||||
@@ -6,18 +6,25 @@ 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>/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>/sig-ready', views.sig_ready, name='sig_ready'),
|
||||||
|
path('room/<uuid:room_id>/sig-confirm', views.sig_confirm, name='sig_confirm'),
|
||||||
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'),
|
||||||
path('room/<uuid:room_id>/abandon', views.abandon_room, name='abandon_room'),
|
path('room/<uuid:room_id>/abandon', views.abandon_room, name='abandon_room'),
|
||||||
path('room/<uuid:room_id>/tarot/', views.tarot_deck, name='tarot_deck'),
|
path('room/<uuid:room_id>/tarot/', views.tarot_deck, name='tarot_deck'),
|
||||||
path('room/<uuid:room_id>/tarot/deal', views.tarot_deal, name='tarot_deal'),
|
path('room/<uuid:room_id>/tarot/deal', views.tarot_deal, name='tarot_deal'),
|
||||||
|
path('room/<uuid:room_id>/natus/preview', views.natus_preview, name='natus_preview'),
|
||||||
|
path('room/<uuid:room_id>/natus/save', views.natus_save, name='natus_save'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
from datetime import timedelta
|
import json
|
||||||
|
import zoneinfo
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import requests as http_requests
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse, JsonResponse
|
||||||
from django.shortcuts import redirect, render
|
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, TarotCard, TarotDeck,
|
Character,
|
||||||
active_sig_seat, debit_token, select_token, sig_deck_cards, sig_seat_order,
|
GateSlot, Room, RoomInvite, SIG_SEAT_ORDER, SigReservation, TableSeat,
|
||||||
|
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 +50,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,13 +76,94 @@ def _notify_role_select_start(room_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _notify_sig_selected(room_id):
|
def _notify_sig_selected(room_id, card_id, role, deck_type='levity'):
|
||||||
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': 'sig_selected'},
|
{'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},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_countdown_start(room_id, polarity, *, seconds):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'cursors_{room_id}_{polarity}',
|
||||||
|
{'type': 'countdown_start', 'polarity': polarity, 'seconds': seconds},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_countdown_cancel(room_id, polarity, *, seconds_remaining):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'cursors_{room_id}_{polarity}',
|
||||||
|
{'type': 'countdown_cancel', 'polarity': polarity, 'seconds_remaining': seconds_remaining},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_polarity_room_done(room_id, polarity):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'room_{room_id}',
|
||||||
|
{'type': 'polarity_room_done', 'polarity': polarity},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _notify_pick_sky_available(room_id):
|
||||||
|
async_to_sync(get_channel_layer().group_send)(
|
||||||
|
f'room_{room_id}',
|
||||||
|
{'type': 'pick_sky_available'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
@@ -135,6 +228,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": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -166,6 +261,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(
|
||||||
@@ -175,10 +272,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)
|
||||||
@@ -186,16 +289,58 @@ 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"),
|
||||||
}
|
}
|
||||||
|
# Tray cell 2: sig card (set once polarity group confirms)
|
||||||
|
_canonical_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
|
||||||
|
ctx["my_tray_sig"] = _canonical_seat.significator if _canonical_seat else None
|
||||||
|
|
||||||
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'
|
||||||
|
|
||||||
|
user_reservation = SigReservation.objects.filter(
|
||||||
|
room=room, gamer=user
|
||||||
|
).first() if user.is_authenticated else None
|
||||||
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["user_ready"] = bool(user_reservation and user_reservation.ready)
|
||||||
ctx["sig_cards"] = sig_deck_cards(room)
|
ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve"
|
||||||
ctx["sig_seats"] = sig_seat_order(room)
|
|
||||||
|
# Has this gamer's polarity already had significators assigned?
|
||||||
|
# (Other polarity still in progress — stay in SIG_SELECT but skip the overlay.)
|
||||||
|
if user_polarity:
|
||||||
|
_polarity_roles = _LEVITY_ROLES if user_polarity == 'levity' else _GRAVITY_ROLES
|
||||||
|
ctx["polarity_done"] = not room.table_seats.filter(
|
||||||
|
role__in=_polarity_roles, significator__isnull=True
|
||||||
|
).exists()
|
||||||
|
else:
|
||||||
|
ctx["polarity_done"] = False
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
@@ -212,10 +357,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)
|
||||||
|
|
||||||
|
|
||||||
@@ -386,17 +539,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
|
||||||
@@ -407,12 +563,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
|
||||||
@@ -429,7 +593,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
|
||||||
@@ -477,13 +641,227 @@ 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
|
||||||
|
if existing and existing.ready:
|
||||||
|
# Gamer released while ready — treat as an implicit WAIT NVM
|
||||||
|
prior = room.events.filter(
|
||||||
|
actor=request.user, verb=GameEvent.SIG_READY
|
||||||
|
).last()
|
||||||
|
if prior and not prior.data.get("retracted"):
|
||||||
|
prior.data["retracted"] = True
|
||||||
|
prior.save(update_fields=["data"])
|
||||||
|
record(room, GameEvent.SIG_UNREADY, actor=request.user)
|
||||||
|
polarity = existing.polarity
|
||||||
|
all_ready = SigReservation.objects.filter(
|
||||||
|
room=room, polarity=polarity, ready=True
|
||||||
|
).count() == 3
|
||||||
|
if all_ready:
|
||||||
|
_notify_countdown_cancel(room_id, polarity, seconds_remaining=12)
|
||||||
|
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 sig_ready(request, room_id):
|
||||||
|
"""Toggle ready/unready for the polarity-room countdown.
|
||||||
|
POST body: action=ready|unready [, seconds_remaining=<int>]
|
||||||
|
"""
|
||||||
|
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 user_seat is None:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
|
action = request.POST.get("action", "ready")
|
||||||
|
reservation = SigReservation.objects.filter(room=room, gamer=request.user).first()
|
||||||
|
|
||||||
|
if action == "ready":
|
||||||
|
if reservation is None:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
if reservation.ready:
|
||||||
|
return HttpResponse(status=200) # idempotent — already ready, don't re-trigger countdown
|
||||||
|
reservation.ready = True
|
||||||
|
reservation.save(update_fields=["ready"])
|
||||||
|
card = reservation.card
|
||||||
|
if card and card.arcana == TarotCard.MIDDLE:
|
||||||
|
_pol_prefix = "Leavened" if reservation.polarity == SigReservation.LEVITY else "Graven"
|
||||||
|
_card_display = f"{_pol_prefix} {card.name_title}"
|
||||||
|
elif card and card.arcana == TarotCard.MAJOR:
|
||||||
|
_base = card.name_title.removeprefix("The ")
|
||||||
|
_pol_suffix = "of Light" if reservation.polarity == SigReservation.LEVITY else "from the Grave"
|
||||||
|
_card_display = f"{_base} {_pol_suffix}"
|
||||||
|
else:
|
||||||
|
_card_display = card.name_title if card else "a card"
|
||||||
|
record(room, GameEvent.SIG_READY, actor=request.user,
|
||||||
|
card_name=_card_display,
|
||||||
|
corner_rank=card.corner_rank if card else "",
|
||||||
|
suit_icon=card.suit_icon if card else "")
|
||||||
|
# Retract the most recent un-retracted SIG_UNREADY (cancellation is now moot)
|
||||||
|
prior_unready = room.events.filter(
|
||||||
|
actor=request.user, verb=GameEvent.SIG_UNREADY
|
||||||
|
).last()
|
||||||
|
if prior_unready and not prior_unready.data.get("retracted"):
|
||||||
|
prior_unready.data["retracted"] = True
|
||||||
|
prior_unready.save(update_fields=["data"])
|
||||||
|
|
||||||
|
# Check if all three in this polarity are now ready
|
||||||
|
polarity = reservation.polarity
|
||||||
|
polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
|
||||||
|
ready_count = SigReservation.objects.filter(
|
||||||
|
room=room, polarity=polarity, ready=True
|
||||||
|
).count()
|
||||||
|
if ready_count == 3:
|
||||||
|
from apps.epic.tasks import schedule_polarity_confirm
|
||||||
|
# Use saved countdown_remaining if a pause was recorded, else 12
|
||||||
|
saved = SigReservation.objects.filter(
|
||||||
|
room=room, polarity=polarity
|
||||||
|
).exclude(countdown_remaining__isnull=True).values_list(
|
||||||
|
"countdown_remaining", flat=True
|
||||||
|
).first()
|
||||||
|
seconds = saved if saved is not None else 12
|
||||||
|
schedule_polarity_confirm(str(room_id), polarity, seconds)
|
||||||
|
_notify_countdown_start(room_id, polarity, seconds=seconds)
|
||||||
|
|
||||||
|
else: # unready
|
||||||
|
if reservation is not None:
|
||||||
|
reservation.ready = False
|
||||||
|
reservation.save(update_fields=["ready"])
|
||||||
|
# Mark the most recent un-retracted SIG_READY event for this actor
|
||||||
|
prior = room.events.filter(
|
||||||
|
actor=request.user, verb=GameEvent.SIG_READY
|
||||||
|
).last()
|
||||||
|
if prior and not prior.data.get("retracted"):
|
||||||
|
prior.data["retracted"] = True
|
||||||
|
prior.save(update_fields=["data"])
|
||||||
|
record(room, GameEvent.SIG_UNREADY, actor=request.user)
|
||||||
|
polarity = reservation.polarity
|
||||||
|
|
||||||
|
# Save remaining seconds on all polarity reservations
|
||||||
|
try:
|
||||||
|
seconds_remaining = int(request.POST.get("seconds_remaining", 12))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
seconds_remaining = 12
|
||||||
|
SigReservation.objects.filter(room=room, polarity=polarity).update(
|
||||||
|
countdown_remaining=seconds_remaining
|
||||||
|
)
|
||||||
|
_notify_countdown_cancel(room_id, polarity, seconds_remaining=seconds_remaining)
|
||||||
|
from apps.epic.tasks import cancel_polarity_confirm
|
||||||
|
cancel_polarity_confirm(str(room_id), polarity)
|
||||||
|
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def sig_confirm(request, room_id):
|
||||||
|
"""Finalise polarity group once the countdown fires.
|
||||||
|
POST body: polarity=levity|gravity
|
||||||
|
"""
|
||||||
|
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 user_seat is None:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
|
polarity = request.POST.get("polarity", SigReservation.LEVITY)
|
||||||
|
polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
|
||||||
|
|
||||||
|
# Idempotency: seats already have significators
|
||||||
|
if not room.table_seats.filter(role__in=polarity_roles, significator__isnull=True).exists():
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
# All three in the polarity group must be ready
|
||||||
|
ready_count = SigReservation.objects.filter(room=room, polarity=polarity, ready=True).count()
|
||||||
|
if ready_count < 3:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
# Assign significators from reservations
|
||||||
|
reservations = list(
|
||||||
|
SigReservation.objects.filter(room=room, polarity=polarity, ready=True)
|
||||||
|
.select_related('seat', 'card')
|
||||||
|
)
|
||||||
|
for res in reservations:
|
||||||
|
if res.seat:
|
||||||
|
res.seat.significator = res.card
|
||||||
|
res.seat.save(update_fields=['significator'])
|
||||||
|
SigReservation.objects.filter(room=room, polarity=polarity).update(countdown_remaining=None)
|
||||||
|
|
||||||
|
_notify_polarity_room_done(room_id, polarity)
|
||||||
|
|
||||||
|
# If both polarities are now done, advance to SKY_SELECT
|
||||||
|
if not room.table_seats.filter(significator__isnull=True).exists():
|
||||||
|
Room.objects.filter(id=room_id).update(table_status=Room.SKY_SELECT)
|
||||||
|
_notify_pick_sky_available(room_id)
|
||||||
|
|
||||||
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def select_sig(request, room_id):
|
def select_sig(request, room_id):
|
||||||
if request.method != "POST":
|
if request.method != "POST":
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect("epic:gatekeeper", room_id=room_id)
|
||||||
room = Room.objects.get(id=room_id)
|
room = Room.objects.get(id=room_id)
|
||||||
if room.table_status != Room.SIG_SELECT:
|
if room.table_status != Room.SIG_SELECT:
|
||||||
return redirect("epic:gatekeeper", room_id=room_id)
|
return redirect(
|
||||||
|
"epic:room" if room.table_status else "epic:gatekeeper",
|
||||||
|
room_id=room_id,
|
||||||
|
)
|
||||||
active_seat = active_sig_seat(room)
|
active_seat = active_sig_seat(room)
|
||||||
if active_seat is None or active_seat.gamer != request.user:
|
if active_seat is None or active_seat.gamer != request.user:
|
||||||
return HttpResponse(status=403)
|
return HttpResponse(status=403)
|
||||||
@@ -499,7 +877,8 @@ def select_sig(request, room_id):
|
|||||||
return HttpResponse(status=409)
|
return HttpResponse(status=409)
|
||||||
active_seat.significator = card
|
active_seat.significator = card
|
||||||
active_seat.save()
|
active_seat.save()
|
||||||
_notify_sig_selected(room_id)
|
deck_type = request.POST.get('deck_type', 'levity')
|
||||||
|
_notify_sig_selected(room_id, card.pk, active_seat.role, deck_type)
|
||||||
return HttpResponse(status=200)
|
return HttpResponse(status=200)
|
||||||
|
|
||||||
|
|
||||||
@@ -541,3 +920,167 @@ def tarot_deal(request, room_id):
|
|||||||
"positions": positions,
|
"positions": positions,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ── Natus (natal chart) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _planet_house(degree, cusps):
|
||||||
|
"""Return 1-based house number for a planet at ecliptic degree.
|
||||||
|
|
||||||
|
cusps is the 12-element list from PySwiss where cusps[i] is the start of
|
||||||
|
house i+1. Handles the wrap-around case where a cusp crosses 0°/360°.
|
||||||
|
"""
|
||||||
|
degree = degree % 360
|
||||||
|
for i in range(12):
|
||||||
|
start = cusps[i] % 360
|
||||||
|
end = cusps[(i + 1) % 12] % 360
|
||||||
|
if start < end:
|
||||||
|
if start <= degree < end:
|
||||||
|
return i + 1
|
||||||
|
else: # wrap-around: e.g. cusp at 350° → next at 10°
|
||||||
|
if degree >= start or degree < end:
|
||||||
|
return i + 1
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_distinctions(planets, houses):
|
||||||
|
"""Return dict {house_number_str: planet_count} for all 12 houses."""
|
||||||
|
cusps = houses['cusps']
|
||||||
|
counts = {str(i): 0 for i in range(1, 13)}
|
||||||
|
for planet_data in planets.values():
|
||||||
|
h = _planet_house(planet_data['degree'], cusps)
|
||||||
|
counts[str(h)] += 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def natus_preview(request, room_id):
|
||||||
|
"""Proxy GET to PySwiss /api/chart/ and augment with distinction counts.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
date — YYYY-MM-DD (local birth date)
|
||||||
|
time — HH:MM (local birth time, default 12:00)
|
||||||
|
tz — IANA timezone string (optional; auto-resolved from lat/lon if absent)
|
||||||
|
lat — float
|
||||||
|
lon — float
|
||||||
|
|
||||||
|
If tz is absent or blank, calls PySwiss /api/tz/ to resolve it from the
|
||||||
|
coordinates before converting the local datetime to UTC.
|
||||||
|
|
||||||
|
Response includes a 'timezone' key (resolved or supplied) so the client
|
||||||
|
can back-fill the timezone field after the first wheel render.
|
||||||
|
|
||||||
|
No database writes — safe for debounced real-time calls.
|
||||||
|
"""
|
||||||
|
seat = _canonical_user_seat(Room.objects.get(id=room_id), request.user)
|
||||||
|
if seat is None:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
|
date_str = request.GET.get('date')
|
||||||
|
time_str = request.GET.get('time', '12:00')
|
||||||
|
tz_str = request.GET.get('tz', '').strip()
|
||||||
|
lat_str = request.GET.get('lat')
|
||||||
|
lon_str = request.GET.get('lon')
|
||||||
|
|
||||||
|
if not date_str or lat_str is None or lon_str is None:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
lat = float(lat_str)
|
||||||
|
lon = float(lon_str)
|
||||||
|
except ValueError:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
# Resolve timezone from coordinates if not supplied
|
||||||
|
if not tz_str:
|
||||||
|
try:
|
||||||
|
tz_resp = http_requests.get(
|
||||||
|
settings.PYSWISS_URL + '/api/tz/',
|
||||||
|
params={'lat': lat_str, 'lon': lon_str},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
tz_resp.raise_for_status()
|
||||||
|
tz_str = tz_resp.json().get('timezone') or 'UTC'
|
||||||
|
except Exception:
|
||||||
|
tz_str = 'UTC'
|
||||||
|
|
||||||
|
try:
|
||||||
|
tz = zoneinfo.ZoneInfo(tz_str)
|
||||||
|
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
local_dt = datetime.strptime(f'{date_str} {time_str}', '%Y-%m-%d %H:%M')
|
||||||
|
local_dt = local_dt.replace(tzinfo=tz)
|
||||||
|
utc_dt = local_dt.astimezone(zoneinfo.ZoneInfo('UTC'))
|
||||||
|
dt_iso = utc_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
except ValueError:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = http_requests.get(
|
||||||
|
settings.PYSWISS_URL + '/api/chart/',
|
||||||
|
params={'dt': dt_iso, 'lat': lat_str, 'lon': lon_str},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except Exception:
|
||||||
|
return HttpResponse(status=502)
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
data['distinctions'] = _compute_distinctions(data['planets'], data['houses'])
|
||||||
|
data['timezone'] = tz_str
|
||||||
|
return JsonResponse(data)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def natus_save(request, room_id):
|
||||||
|
"""Create or update the draft Character for the requesting gamer's seat.
|
||||||
|
|
||||||
|
POST body (JSON):
|
||||||
|
birth_dt — ISO 8601 UTC datetime
|
||||||
|
birth_lat — float
|
||||||
|
birth_lon — float
|
||||||
|
birth_place — display string (optional)
|
||||||
|
house_system — single char, default 'O'
|
||||||
|
chart_data — full PySwiss response dict (incl. distinctions)
|
||||||
|
action — 'save' (default) or 'confirm'
|
||||||
|
|
||||||
|
On 'confirm': sets confirmed_at, locking the Character.
|
||||||
|
Returns: {id, confirmed}
|
||||||
|
"""
|
||||||
|
if request.method != 'POST':
|
||||||
|
return HttpResponse(status=405)
|
||||||
|
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
seat = _canonical_user_seat(room, request.user)
|
||||||
|
if seat is None:
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
# Find or create the active draft (unconfirmed, unretired) for this seat
|
||||||
|
char = Character.objects.filter(
|
||||||
|
seat=seat, confirmed_at__isnull=True, retired_at__isnull=True,
|
||||||
|
).first()
|
||||||
|
if char is None:
|
||||||
|
char = Character(seat=seat)
|
||||||
|
|
||||||
|
char.birth_dt = body.get('birth_dt')
|
||||||
|
char.birth_lat = body.get('birth_lat')
|
||||||
|
char.birth_lon = body.get('birth_lon')
|
||||||
|
char.birth_place = body.get('birth_place', '')
|
||||||
|
char.house_system = body.get('house_system', Character.PORPHYRY)
|
||||||
|
char.chart_data = body.get('chart_data')
|
||||||
|
|
||||||
|
if body.get('action') == 'confirm':
|
||||||
|
char.confirmed_at = timezone.now()
|
||||||
|
|
||||||
|
char.save()
|
||||||
|
return JsonResponse({'id': char.id, 'confirmed': char.is_confirmed})
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
// 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.top = Math.round(tokenRect.top) + 'px';
|
||||||
portal.style.transform = `translate(-50%, calc(-100% - 0.5rem - ${miniHeight}px))`;
|
portal.style.transform = `translate(-50%, calc(-100% - 0.5rem - ${miniHeight}px))`;
|
||||||
|
}
|
||||||
|
|
||||||
if (isEquippable) {
|
if (isEquippable) {
|
||||||
const mainRect = portal.getBoundingClientRect();
|
const mainRect = portal.getBoundingClientRect();
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M433.7 355.4C442.7 350.9 453.3 350.9 462.3 355.4L590.3 419.4C606.1 427.3 612.5 446.5 604.6 462.3C596.7 478.1 577.5 484.5 561.7 476.6L448 419.8L334.3 476.6C325.3 481.1 314.7 481.1 305.7 476.6L192 419.8L78.3 476.6C62.5 484.5 43.3 478.1 35.4 462.3C27.5 446.5 33.9 427.3 49.7 419.4L177.7 355.4C186.7 350.9 197.3 350.9 206.3 355.4L320 412.2L433.7 355.4zM437.1 161.9C445.3 158.9 454.4 159.4 462.3 163.4L590.3 227.4C606.1 235.3 612.5 254.5 604.6 270.3C596.7 286.1 577.5 292.5 561.7 284.6L448 227.8L334.3 284.6C325.3 289.1 314.7 289.1 305.7 284.6L192 227.8L78.3 284.6C62.5 292.5 43.3 286.1 35.4 270.3C27.5 254.5 33.9 235.3 49.7 227.4L177.7 163.4L181.1 161.9C189.3 158.9 198.4 159.4 206.3 163.4L320 220.2L433.7 163.4L437.1 161.9z"/></svg>
|
||||||
|
After Width: | Height: | Size: 801 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M465.6 64C526.9 64 576 114.3 576 175C576 204.4 564.4 232.9 543.4 253.8L534.6 262.6C522.1 275.1 501.8 275.1 489.4 262.6C477 250.1 476.9 229.8 489.4 217.4L498.2 208.6C507 199.8 512 187.6 512 175C512 149.1 490.9 128 465.6 128C443.1 128 423.9 144.1 419.9 166.2L351.5 549.6C351.5 549.7 351.4 549.9 351.4 550C351.2 550.8 351.1 551.6 350.9 552.3C350.8 552.6 350.8 552.8 350.7 553C350.5 553.6 350.3 554.3 350 554.9C349.8 555.4 349.6 555.9 349.4 556.4C349.3 556.7 349.1 557 349 557.3C347.4 560.8 345.2 563.8 342.6 566.5C340.6 568.5 338.4 570.2 336 571.6C335.8 571.7 335.7 571.8 335.5 571.9C334.8 572.3 334.1 572.6 333.4 572.9C333.1 573 332.8 573.2 332.5 573.3C332 573.5 331.5 573.7 331 573.9C330.4 574.1 329.8 574.4 329.1 574.6C328.9 574.7 328.6 574.8 328.4 574.8C327.6 575 326.8 575.2 326.1 575.3C325.9 575.3 325.8 575.4 325.7 575.4C325.5 575.4 325.3 575.4 325.1 575.5C324.4 575.6 323.8 575.7 323.1 575.7C322.7 575.7 322.3 575.8 321.9 575.8C321.3 575.8 320.7 575.9 320.1 575.9C319.5 575.9 318.9 575.9 318.3 575.8C317.9 575.8 317.5 575.7 317.1 575.7C316.4 575.6 315.7 575.6 315.1 575.5C314.9 575.5 314.7 575.5 314.5 575.4C314.3 575.4 314.2 575.3 314 575.3C313.2 575.1 312.4 575 311.7 574.8C311.4 574.7 311.2 574.7 310.9 574.6C310.3 574.4 309.6 574.2 309 573.9C308.5 573.7 308 573.5 307.5 573.3C307.2 573.2 306.9 573 306.6 572.9C305.9 572.6 305.2 572.2 304.5 571.9C304.3 571.8 304.2 571.7 304 571.6C301.6 570.2 299.3 568.5 297.4 566.5C294.8 563.9 292.6 560.8 291 557.3C290.9 557.1 290.8 557 290.8 556.8L290.3 555.7C290.2 555.4 290.1 555.2 290 554.9C289.8 554.3 289.5 553.7 289.3 553C289.2 552.8 289.1 552.5 289.1 552.3C288.9 551.5 288.7 550.7 288.6 550C288.6 549.9 288.5 549.7 288.5 549.6L220 166.2C216 144.1 196.8 128 174.3 128C149 128 127.9 149.1 127.9 175C127.9 187.6 132.9 199.8 141.7 208.6L150.5 217.4C163 229.9 163 250.2 150.5 262.6C138 275 117.7 275.1 105.3 262.6L96.5 253.8C75.6 232.9 64 204.3 64 175C64 114.3 113.1 64 174.4 64C227.8 64 273.6 102.3 283 155L320 362L357 155C366.4 102.4 412.2 64 465.7 64z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M472 216C529.4 216 576 262.6 576 320C576 461.4 461.4 576 320 576C265.7 576 215.2 559 173.7 530.1C159.2 520 155.7 500 165.8 485.5C175.9 471 195.9 467.5 210.4 477.6C241.5 499.3 279.3 512 320.1 512C388 512 447.6 476.7 481.8 423.5C478.6 423.8 475.4 424 472.1 424C414.7 424 368.1 377.4 368.1 320C368.1 262.6 414.7 216 472.1 216zM320 64C374.3 64 424.8 81 466.3 109.9C480.8 120 484.3 140 474.2 154.5C464.1 169 444.1 172.5 429.6 162.4C398.5 140.7 360.7 128 319.9 128C252 128 192.4 163.2 158.2 216.4C161.4 216.1 164.6 216 167.9 216C225.3 216 271.9 262.6 271.9 320C271.9 377.4 225.4 424 168 424C110.6 424 64 377.4 64 320C64 318.1 64 316.2 64.1 314.4C67.1 175.6 180.5 64 320 64zM168 280C145.9 280 128 297.9 128 320C128 342.1 145.9 360 168 360C190.1 360 208 342.1 208 320C208 297.9 190.1 280 168 280zM472 280C449.9 280 432 297.9 432 320C432 342.1 449.9 360 472 360C494.1 360 512 342.1 512 320C512 297.9 494.1 280 472 280z"/></svg>
|
||||||
|
After Width: | Height: | Size: 990 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M272 96C351.5 96 416 160.5 416 240L416 305.2C434.8 294.3 456.7 288 480 288C550.7 288 608 345.3 608 416C608 486.7 550.7 544 480 544C444.2 544 411.8 529.3 388.6 505.6C359.8 548.1 311.2 576 256 576C238.3 576 224 561.7 224 544C224 526.3 238.3 512 256 512C309 512 352 469 352 416L352 240C352 195.8 316.2 160 272 160C227.8 160 192 195.8 192 240L192 448C192 465.7 177.7 480 160 480C142.3 480 128 465.7 128 448L128 224C128 188.7 99.3 160 64 160C46.3 160 32 145.7 32 128C32 110.3 46.3 96 64 96C104.6 96 140.8 115 164.2 144.5C190.6 114.7 229.1 96 272 96zM480 352C444.7 352 416 380.7 416 416C416 451.3 444.7 480 480 480C515.3 480 544 451.3 544 416C544 380.7 515.3 352 480 352z"/></svg>
|
||||||
|
After Width: | Height: | Size: 746 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M491.4 72C504.6 60.3 524.7 61.5 536.5 74.7C548.2 87.9 547 108.1 533.8 119.9C531.1 122.3 510.7 139.7 475.5 156.7C467.3 160.7 458.3 164.6 448.5 168.3L448.5 471.5C458.3 475.3 467.3 479.1 475.5 483.1C493.7 491.9 507.9 500.8 517.7 507.6C520.7 509.7 523.2 511.7 525.4 513.3C526.6 514.2 527.7 514.9 528.6 515.7C528.8 515.8 528.9 515.9 529.1 516.1C530.4 517.2 531.5 518.1 532.3 518.7C532.7 519 533 519.3 533.2 519.5C533.3 519.6 533.5 519.7 533.5 519.8C533.5 519.8 533.6 519.9 533.6 519.9L533.7 519.9L533.7 519.9L533.7 519.9C537 522.8 539.6 526.3 541.4 530.1C546.8 541.4 545.2 555.2 536.4 565.1C536.1 565.5 535.7 565.8 535.3 566.2C523.4 578.4 504.1 579.3 491.3 567.9C491 567.6 489.8 566.6 487.8 565.1C487.4 564.8 486.9 564.4 486.3 564C484.4 562.6 482.1 560.9 479.2 558.9C477 557.4 474.5 555.8 471.8 554.1C465.2 550 457 545.3 447.4 540.6C440 537 431.8 533.4 422.7 530.1C409.1 525 393.7 520.5 376.6 517.2C370.9 516.1 365 515.1 358.9 514.4C346.8 512.8 333.9 511.9 320.3 511.9C266.1 511.9 222.9 526.3 193.3 540.7C189.9 542.3 186.8 544 183.8 545.6C178.7 548.4 174.1 551 170 553.5C166.7 555.6 163.7 557.5 161.1 559.2C159.4 560.4 157.9 561.5 156.5 562.5C154.6 563.9 153 565 151.9 565.9C150.5 567 149.7 567.7 149.4 567.9C136.2 579.6 116.1 578.4 104.3 565.1C102.1 562.6 100.3 559.9 99.1 557C97.9 554.1 97 551.1 96.6 548.1C95.2 537.9 98.8 527.3 107 519.9C107.2 519.7 107.6 519.4 108.2 518.9C108.3 518.8 108.4 518.7 108.5 518.6L111.7 516C113.4 514.7 115.4 513.2 117.9 511.4C119.5 510.2 121.2 508.9 123.1 507.6C132.9 500.8 147.1 491.9 165.3 483.1C173.5 479.1 182.5 475.2 192.3 471.5L192.3 168.3C182.7 164.6 173.7 160.7 165.5 156.8C130.3 139.7 110 122.3 107.3 119.9C94.1 108.2 92.9 88 104.6 74.7C116.3 61.5 136.5 60.3 149.7 72C151.1 73.2 166.7 86.2 193.5 99.2C223.1 113.6 266.3 128 320.5 128C374.7 128 417.9 113.6 447.5 99.2C474.3 86.2 489.9 73.2 491.3 72zM384.5 186.3C364.8 189.8 343.5 192 320.5 192C297.5 192 276.2 189.8 256.5 186.3L256.5 453.7C269.1 451.4 282.4 449.7 296.4 448.8C304.2 448.3 312.2 448 320.4 448C343.4 448 364.8 450.2 384.4 453.7L384.4 186.3z"/></svg>
|
||||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M324 64C396.9 64 456 123.1 456 196L456 201.4L455.9 202.8L432.1 472.8C432.5 494.5 450.3 512 472.1 512C494.2 512 512.1 494.1 512.1 472L512.1 432C512.1 414.3 526.4 400 544.1 400C561.8 400 576.1 414.3 576.1 432L576.1 472C576.1 529.4 529.5 576 472.1 576C414.7 576 368 529.4 368 472L368 470.6L368.1 469.2L392 198.6L392 196C392 158.4 361.6 128 324 128C286.4 128 256 158.4 256 196L256 200C256 203.7 256.3 207.3 256.8 211L286.5 404.4C287.4 410.5 287.9 416.6 287.9 422.8L287.9 432C287.9 493.9 237.8 544 175.9 544C114 544 64 493.9 64 432C64 370.1 114.1 320 176 320C187.7 320 199 321.8 209.7 325.1L193.6 220.7C192.5 213.9 192 206.9 192 200L192 196C192 123.1 251.1 64 324 64zM176 384C149.5 384 128 405.5 128 432C128 458.5 149.5 480 176 480C202.5 480 224 458.5 224 432C224 405.5 202.5 384 176 384z"/></svg>
|
||||||
|
After Width: | Height: | Size: 864 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M544 448C561.7 448 576 462.3 576 480C576 497.7 561.7 512 544 512L96 512C78.3 512 64 497.7 64 480C64 462.3 78.3 448 96 448L544 448zM320 96C417.2 96 496 174.8 496 272C496 288.6 493.6 304.7 489.3 320L544 320C561.7 320 576 334.3 576 352C576 369.7 561.7 384 544 384L439.8 384C428 384 417.1 377.5 411.6 367.1C406.1 356.7 406.7 344 413.2 334.2C425.1 316.4 432 295.1 432 272C432 210.1 381.9 160 320 160C258.1 160 208 210.1 208 272C208 295.1 214.9 316.4 226.8 334.2C233.4 344 234 356.7 228.4 367.1C222.8 377.5 212.1 384 200.2 384L96 384C78.3 384 64 369.7 64 352C64 334.3 78.3 320 96 320L150.8 320C146.5 304.7 144 288.6 144 272C144 174.8 222.8 96 320 96z"/></svg>
|
||||||
|
After Width: | Height: | Size: 725 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M136.4 74.4C148.3 61.4 168.6 60.5 181.6 72.4C184.5 75.1 209.9 99.1 235.2 140.2C257.5 176.4 280.2 226.8 286.3 288L353.6 288C359.7 226.9 382.4 176.4 404.7 140.2C430 99.1 455.4 75.1 458.3 72.4C471.3 60.5 491.6 61.3 503.5 74.4C515.4 87.4 514.5 107.7 501.5 119.6C500.1 120.9 479.5 140.7 459.1 173.8C441.2 202.9 423.7 241.8 417.9 288L511.9 288L515.2 288.2C531.3 289.8 543.9 303.5 543.9 320C543.9 336.5 531.3 350.2 515.2 351.8L511.9 352L417.9 352C423.7 398.2 441.2 437.1 459.1 466.2C479.5 499.3 500.1 519.1 501.5 520.4C514.5 532.3 515.4 552.6 503.5 565.6C491.6 578.6 471.3 579.5 458.3 567.6C455.4 564.9 430 540.9 404.7 499.8C382.4 463.6 359.7 413.2 353.6 352L286.3 352C280.2 413.1 257.5 463.6 235.2 499.8C209.9 540.9 184.5 564.9 181.6 567.6C168.6 579.5 148.3 578.7 136.4 565.6C124.5 552.6 125.4 532.3 138.4 520.4C139.8 519.1 160.4 499.3 180.8 466.2C198.7 437.1 216.2 398.2 222 352L128 352C110.3 352 96 337.7 96 320C96 302.3 110.3 288 128 288L222 288C216.2 241.8 198.7 202.9 180.8 173.8C160.4 140.7 139.8 120.9 138.4 119.6C125.4 107.7 124.5 87.4 136.4 74.4z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M512 96C529.7 96 544 110.3 544 128L544 288C544 305.7 529.7 320 512 320C494.3 320 480 305.7 480 288L480 205.2L333.2 352L374.6 393.4C387.1 405.9 387.1 426.2 374.6 438.6C362.1 451 341.8 451.1 329.3 438.6L288 397.3L150.6 534.6C138.1 547.1 117.8 547.1 105.4 534.6C93 522.1 92.9 501.8 105.4 489.4L242.8 352L201.4 310.6C188.9 298.1 188.9 277.8 201.4 265.4C213.9 253 234.2 252.9 246.6 265.4L288 306.8L434.8 160L352 160C334.3 160 320 145.7 320 128C320 110.3 334.3 96 352 96L512 96z"/></svg>
|
||||||
|
After Width: | Height: | Size: 553 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M368 96C429.9 96 480 146.1 480 208L480 497.7C480 505.6 486.4 512 494.3 512C498.2 512 501.9 510.4 504.5 507.7L544.6 466.6L534.9 456.9C528 450 526 439.7 529.7 430.7C533.4 421.7 542.3 416 552 416L616 416L618.5 416.1C630.6 417.3 640 427.6 640 440L640 504L639.7 507.6C638.5 515.8 633 522.9 625.2 526.2C616.2 529.9 605.9 527.8 599 521L589.9 511.9L550.3 552.5C535.6 567.6 515.4 576.1 494.3 576.1C451 576.1 416 541 416 497.8L416 208C416 181.5 394.5 160 368 160C341.5 160 320 181.5 320 208L320 512C320 529.7 305.7 544 288 544C270.3 544 256 529.7 256 512L256 208C256 181.5 234.5 160 208 160C181.5 160 160 181.5 160 208L160 512C160 529.7 145.7 544 128 544C110.3 544 96 529.7 96 512L96 192C96 175.4 83.4 161.8 67.3 160.2L60.7 159.9C44.6 158.2 32 144.6 32 128C32 110.3 46.3 96 64 96C91 96 115.3 107.1 132.7 125C152.6 107 179 96 208 96C239.3 96 267.7 108.9 288 129.7C308.3 108.9 336.6 96 368 96z"/></svg>
|
||||||
|
After Width: | Height: | Size: 962 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M464 48C481.7 48 496 62.3 496 80C496 138.5 467.4 190.3 423.5 222.3C476.7 256.4 512 316.1 512 384C512 490 426 576 320 576C214 576 128 490 128 384C128 316.1 163.3 256.4 216.5 222.3C172.6 190.3 144 138.5 144 80C144 62.3 158.3 48 176 48C193.7 48 208 62.3 208 80C208 141.9 258.1 192 320 192C381.9 192 432 141.9 432 80C432 62.3 446.3 48 464 48zM320 256C249.3 256 192 313.3 192 384C192 454.7 249.3 512 320 512C390.7 512 448 454.7 448 384C448 313.3 390.7 256 320 256z"/></svg>
|
||||||
|
After Width: | Height: | Size: 540 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M368 96C429.9 96 480 146.1 480 208L480 234.8C494.5 227.9 510.8 224 528 224C589.9 224 640 274.1 640 336C640 397.9 589.9 448 528 448L480 448L480 480C480 496.6 492.6 510.2 508.7 511.8L515.3 512.1C531.4 513.7 544 527.4 544 543.9C544 561.6 529.7 575.9 512 575.9C459 575.9 416 532.9 416 479.9L416 447.9L400 447.9C382.3 447.9 368 433.6 368 415.9C368 398.2 382.3 383.9 400 383.9L416 383.9L416 207.9C416 181.4 394.5 159.9 368 159.9C341.5 159.9 320 181.4 320 207.9L320 511.9C320 529.6 305.7 543.9 288 543.9C270.3 543.9 256 529.6 256 511.9L256 207.9C256 181.4 234.5 159.9 208 159.9C181.5 159.9 160 181.4 160 207.9L160 511.9C160 529.6 145.7 543.9 128 543.9C110.3 543.9 96 529.6 96 511.9L96 191.9C96 175.3 83.4 161.7 67.3 160.1L60.7 159.8C44.6 158.2 32 144.6 32 128C32 110.3 46.3 96 64 96C91 96 115.3 107.1 132.7 125C152.6 107 179 96 208 96C239.3 96 267.7 108.9 288 129.7C308.3 108.9 336.6 96 368 96zM528 288C501.5 288 480 309.5 480 336L480 384L528 384C554.5 384 576 362.5 576 336C576 309.5 554.5 288 528 288z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
452
src/apps/gameboard/static/apps/gameboard/natus-wheel.js
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
/**
|
||||||
|
* natus-wheel.js — Self-contained D3 natal-chart module.
|
||||||
|
*
|
||||||
|
* Public API:
|
||||||
|
* NatusWheel.draw(svgEl, data) — first render
|
||||||
|
* NatusWheel.redraw(data) — live update (same SVG)
|
||||||
|
* NatusWheel.clear() — empty the SVG
|
||||||
|
*
|
||||||
|
* `data` shape — matches the /epic/natus/preview/ proxy response:
|
||||||
|
* {
|
||||||
|
* planets: { Sun: { sign, degree, retrograde }, … },
|
||||||
|
* houses: { cusps: [f×12], asc: f, mc: f },
|
||||||
|
* elements: { Fire: n, Water: n, Stone: n, Air: n, Time: n, Space: n },
|
||||||
|
* aspects: [{ planet1, planet2, type, angle, orb }, …],
|
||||||
|
* distinctions: { "1": n, …, "12": n },
|
||||||
|
* house_system: "O",
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Requires D3 v7 to be available as `window.d3` (loaded before this file).
|
||||||
|
* Uses CSS variables from the project palette (--priUser, --secUser, etc.)
|
||||||
|
* already defined in the page; falls back to neutral colours if absent.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NatusWheel = (() => {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SIGNS = [
|
||||||
|
{ name: 'Aries', symbol: '♈', element: 'Fire', fa: 'aries' },
|
||||||
|
{ name: 'Taurus', symbol: '♉', element: 'Stone', fa: 'taurus' },
|
||||||
|
{ name: 'Gemini', symbol: '♊', element: 'Air', fa: 'gemini' },
|
||||||
|
{ name: 'Cancer', symbol: '♋', element: 'Water', fa: 'cancer' },
|
||||||
|
{ name: 'Leo', symbol: '♌', element: 'Fire', fa: 'leo' },
|
||||||
|
{ name: 'Virgo', symbol: '♍', element: 'Stone', fa: 'virgo' },
|
||||||
|
{ name: 'Libra', symbol: '♎', element: 'Air', fa: 'libra' },
|
||||||
|
{ name: 'Scorpio', symbol: '♏', element: 'Water', fa: 'scorpio' },
|
||||||
|
{ name: 'Sagittarius', symbol: '♐', element: 'Fire', fa: 'sagittarius' },
|
||||||
|
{ name: 'Capricorn', symbol: '♑', element: 'Stone', fa: 'capricorn' },
|
||||||
|
{ name: 'Aquarius', symbol: '♒', element: 'Air', fa: 'aquarius' },
|
||||||
|
{ name: 'Pisces', symbol: '♓', element: 'Water', fa: 'pisces' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLANET_SYMBOLS = {
|
||||||
|
Sun: '☉', Moon: '☽', Mercury: '☿', Venus: '♀', Mars: '♂',
|
||||||
|
Jupiter: '♃', Saturn: '♄', Uranus: '♅', Neptune: '♆', Pluto: '♇',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Alchemical element symbol → CSS modifier class suffix (matches rootvars palette)
|
||||||
|
const PLANET_ELEMENTS = {
|
||||||
|
Sun: 'au', Moon: 'ag', Mercury: 'hg', Venus: 'cu', Mars: 'fe',
|
||||||
|
Jupiter: 'sn', Saturn: 'pb', Uranus: 'u', Neptune: 'np', Pluto: 'pu',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aspect stroke colors remain in JS — they are data-driven, not stylistic.
|
||||||
|
const ASPECT_COLORS = {
|
||||||
|
Conjunction: 'var(--priYl, #f0e060)',
|
||||||
|
Sextile: 'var(--priGn, #60c080)',
|
||||||
|
Square: 'var(--priRd, #c04040)',
|
||||||
|
Trine: 'var(--priGn, #60c080)',
|
||||||
|
Opposition: 'var(--priRd, #c04040)',
|
||||||
|
};
|
||||||
|
// Element fill colors live in _natus.scss (.nw-sign--* / .nw-element--*).
|
||||||
|
|
||||||
|
const HOUSE_LABELS = [
|
||||||
|
'', 'Self', 'Worth', 'Education', 'Family', 'Creation', 'Ritual',
|
||||||
|
'Cooperation', 'Regeneration', 'Enterprise', 'Career', 'Reward', 'Reprisal',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _svg = null;
|
||||||
|
let _cx, _cy, _r; // centre + outer radius
|
||||||
|
|
||||||
|
// Cached SVG path strings keyed by sign name, populated by preload().
|
||||||
|
const _signPaths = {};
|
||||||
|
|
||||||
|
// Ring radii (fractions of _r, set in _layout)
|
||||||
|
let R = {};
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Convert ecliptic longitude to SVG angle.
|
||||||
|
*
|
||||||
|
* American convention: ASC sits at 9 o'clock (left). SVG 0° is 3 o'clock
|
||||||
|
* and increases clockwise, so ecliptic (counter-clockwise, ASC-relative)
|
||||||
|
* maps to SVG via:
|
||||||
|
* svg_angle = -(ecliptic - asc) - 180° (in radians)
|
||||||
|
* The −180° offset places ASC exactly at the left (9 o'clock) position.
|
||||||
|
*/
|
||||||
|
function _toAngle(degree, asc) {
|
||||||
|
return (-(degree - asc) - 180) * Math.PI / 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _css(varName, fallback) {
|
||||||
|
const v = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue(varName).trim();
|
||||||
|
return v || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _layout(svgEl) {
|
||||||
|
const rect = svgEl.getBoundingClientRect();
|
||||||
|
const size = Math.min(rect.width || 400, rect.height || 400);
|
||||||
|
_cx = size / 2;
|
||||||
|
_cy = size / 2;
|
||||||
|
_r = size * 0.46; // leave a small margin
|
||||||
|
|
||||||
|
R = {
|
||||||
|
elementInner: _r * 0.20,
|
||||||
|
elementOuter: _r * 0.28,
|
||||||
|
planetInner: _r * 0.32,
|
||||||
|
planetOuter: _r * 0.48,
|
||||||
|
houseInner: _r * 0.50,
|
||||||
|
houseOuter: _r * 0.68,
|
||||||
|
signInner: _r * 0.70,
|
||||||
|
signOuter: _r * 0.90,
|
||||||
|
labelR: _r * 0.80, // sign symbol placement
|
||||||
|
houseNumR: _r * 0.59, // house number placement
|
||||||
|
planetR: _r * 0.40, // planet symbol placement
|
||||||
|
aspectR: _r * 0.29, // aspect lines end here (inner circle)
|
||||||
|
ascMcR: _r * 0.92, // ASC/MC tick outer
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drawing sub-routines ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _drawAscMc(g, data) {
|
||||||
|
const asc = data.houses.asc;
|
||||||
|
const mc = data.houses.mc;
|
||||||
|
const points = [
|
||||||
|
{ deg: asc, label: 'ASC' },
|
||||||
|
{ deg: asc + 180, label: 'DSC' },
|
||||||
|
{ deg: mc, label: 'MC' },
|
||||||
|
{ deg: mc + 180, label: 'IC' },
|
||||||
|
];
|
||||||
|
const axisGroup = g.append('g').attr('class', 'nw-axes');
|
||||||
|
points.forEach(({ deg, label }) => {
|
||||||
|
const a = _toAngle(deg, asc);
|
||||||
|
const x1 = _cx + R.houseInner * Math.cos(a);
|
||||||
|
const y1 = _cy + R.houseInner * Math.sin(a);
|
||||||
|
const x2 = _cx + R.ascMcR * Math.cos(a);
|
||||||
|
const y2 = _cy + R.ascMcR * Math.sin(a);
|
||||||
|
axisGroup.append('line')
|
||||||
|
.attr('x1', x1).attr('y1', y1)
|
||||||
|
.attr('x2', x2).attr('y2', y2)
|
||||||
|
.attr('class', 'nw-axis-line');
|
||||||
|
axisGroup.append('text')
|
||||||
|
.attr('x', _cx + (R.ascMcR + 12) * Math.cos(a))
|
||||||
|
.attr('y', _cy + (R.ascMcR + 12) * Math.sin(a))
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dominant-baseline', 'middle')
|
||||||
|
.attr('font-size', `${_r * 0.055}px`)
|
||||||
|
.attr('class', 'nw-axis-label')
|
||||||
|
.text(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _drawSigns(g, data) {
|
||||||
|
const asc = data.houses.asc;
|
||||||
|
const arc = d3.arc();
|
||||||
|
const sigGroup = g.append('g').attr('class', 'nw-signs');
|
||||||
|
|
||||||
|
SIGNS.forEach((sign, i) => {
|
||||||
|
const startDeg = i * 30; // ecliptic 0–360
|
||||||
|
const endDeg = startDeg + 30;
|
||||||
|
const startA = _toAngle(startDeg, asc);
|
||||||
|
const endA = _toAngle(endDeg, asc);
|
||||||
|
// D3 arc expects startAngle < endAngle in its own convention; we swap
|
||||||
|
// because our _toAngle goes counter-clockwise
|
||||||
|
const [sa, ea] = startA > endA ? [endA, startA] : [startA, endA];
|
||||||
|
|
||||||
|
sigGroup.append('path')
|
||||||
|
.attr('transform', `translate(${_cx},${_cy})`)
|
||||||
|
.attr('d', arc({
|
||||||
|
innerRadius: R.signInner,
|
||||||
|
outerRadius: R.signOuter,
|
||||||
|
startAngle: sa + Math.PI / 2,
|
||||||
|
endAngle: ea + Math.PI / 2,
|
||||||
|
}))
|
||||||
|
.attr('class', `nw-sign--${sign.element.toLowerCase()}`);
|
||||||
|
|
||||||
|
// Icon at midpoint
|
||||||
|
const midA = (sa + ea) / 2;
|
||||||
|
const lx = _cx + R.labelR * Math.cos(midA);
|
||||||
|
const ly = _cy + R.labelR * Math.sin(midA);
|
||||||
|
const cr = _r * 0.065; // slightly larger than planet circles so icons breathe
|
||||||
|
// scale the 640×640 icon viewBox down to 85% of the circle diameter
|
||||||
|
const sf = (cr * 2 * 0.85) / 640;
|
||||||
|
|
||||||
|
// Colored circle behind icon
|
||||||
|
sigGroup.append('circle')
|
||||||
|
.attr('cx', lx)
|
||||||
|
.attr('cy', ly)
|
||||||
|
.attr('r', cr)
|
||||||
|
.attr('class', `nw-sign-icon-bg--${sign.element.toLowerCase()}`);
|
||||||
|
|
||||||
|
// Inline SVG path — translate origin to label centre, scale, re-centre icon
|
||||||
|
if (_signPaths[sign.name]) {
|
||||||
|
sigGroup.append('path')
|
||||||
|
.attr('d', _signPaths[sign.name])
|
||||||
|
.attr('transform',
|
||||||
|
`translate(${lx},${ly}) scale(${sf}) translate(-320,-320)`)
|
||||||
|
.attr('class', `nw-sign-icon nw-sign-icon--${sign.element.toLowerCase()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _drawHouses(g, data) {
|
||||||
|
const { cusps, asc } = data.houses;
|
||||||
|
const arc = d3.arc();
|
||||||
|
const houseGroup = g.append('g').attr('class', 'nw-houses');
|
||||||
|
|
||||||
|
// Pre-compute angles; normalise the last house's nextCusp across 360° wrap.
|
||||||
|
const houses = cusps.map((cusp, i) => {
|
||||||
|
let nextCusp = cusps[(i + 1) % 12];
|
||||||
|
if (nextCusp <= cusp) nextCusp += 360; // close the circle for house 12
|
||||||
|
const startA = _toAngle(cusp, asc);
|
||||||
|
const endA = _toAngle(nextCusp, asc);
|
||||||
|
// _toAngle is strictly decreasing with degree after normalisation,
|
||||||
|
// so startA > endA always — D3 arc needs sa < ea.
|
||||||
|
const sa = endA, ea = startA;
|
||||||
|
return { i, startA, sa, ea, midA: (sa + ea) / 2 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Fills first so cusp lines + numbers are never buried beneath them.
|
||||||
|
houses.forEach(({ i, sa, ea }) => {
|
||||||
|
houseGroup.append('path')
|
||||||
|
.attr('transform', `translate(${_cx},${_cy})`)
|
||||||
|
.attr('d', arc({
|
||||||
|
innerRadius: R.houseInner,
|
||||||
|
outerRadius: R.houseOuter,
|
||||||
|
startAngle: sa + Math.PI / 2,
|
||||||
|
endAngle: ea + Math.PI / 2,
|
||||||
|
}))
|
||||||
|
.attr('class', i % 2 === 0 ? 'nw-house-fill--even' : 'nw-house-fill--odd');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Cusp lines + house numbers on top.
|
||||||
|
houses.forEach(({ i, startA, midA }) => {
|
||||||
|
houseGroup.append('line')
|
||||||
|
.attr('x1', _cx + R.houseInner * Math.cos(startA))
|
||||||
|
.attr('y1', _cy + R.houseInner * Math.sin(startA))
|
||||||
|
.attr('x2', _cx + R.signInner * Math.cos(startA))
|
||||||
|
.attr('y2', _cy + R.signInner * Math.sin(startA))
|
||||||
|
.attr('class', 'nw-house-cusp');
|
||||||
|
|
||||||
|
houseGroup.append('text')
|
||||||
|
.attr('x', _cx + R.houseNumR * Math.cos(midA))
|
||||||
|
.attr('y', _cy + R.houseNumR * Math.sin(midA))
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dominant-baseline', 'middle')
|
||||||
|
.attr('font-size', `${_r * 0.05}px`)
|
||||||
|
.attr('class', 'nw-house-num')
|
||||||
|
.text(i + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _drawPlanets(g, data) {
|
||||||
|
const asc = data.houses.asc;
|
||||||
|
const planetGroup = g.append('g').attr('class', 'nw-planets');
|
||||||
|
const ascAngle = _toAngle(asc, asc); // start position for animation
|
||||||
|
|
||||||
|
Object.entries(data.planets).forEach(([name, pdata], idx) => {
|
||||||
|
const finalA = _toAngle(pdata.degree, asc);
|
||||||
|
const el = PLANET_ELEMENTS[name] || '';
|
||||||
|
|
||||||
|
// Circle behind symbol
|
||||||
|
const circleBase = pdata.retrograde ? 'nw-planet-circle--rx' : 'nw-planet-circle';
|
||||||
|
const circle = planetGroup.append('circle')
|
||||||
|
.attr('cx', _cx + R.planetR * Math.cos(ascAngle))
|
||||||
|
.attr('cy', _cy + R.planetR * Math.sin(ascAngle))
|
||||||
|
.attr('r', _r * 0.05)
|
||||||
|
.attr('class', el ? `${circleBase} nw-planet--${el}` : circleBase);
|
||||||
|
|
||||||
|
// Symbol
|
||||||
|
const label = planetGroup.append('text')
|
||||||
|
.attr('x', _cx + R.planetR * Math.cos(ascAngle))
|
||||||
|
.attr('y', _cy + R.planetR * Math.sin(ascAngle))
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dominant-baseline', 'middle')
|
||||||
|
.attr('dy', '0.1em')
|
||||||
|
.attr('font-size', `${_r * 0.09}px`)
|
||||||
|
.attr('class', el ? `nw-planet-label nw-planet-label--${el}` : 'nw-planet-label')
|
||||||
|
.text(PLANET_SYMBOLS[name] || name[0]);
|
||||||
|
|
||||||
|
// Retrograde indicator
|
||||||
|
if (pdata.retrograde) {
|
||||||
|
planetGroup.append('text')
|
||||||
|
.attr('x', _cx + (R.planetR + _r * 0.055) * Math.cos(ascAngle))
|
||||||
|
.attr('y', _cy + (R.planetR + _r * 0.055) * Math.sin(ascAngle))
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dominant-baseline', 'middle')
|
||||||
|
.attr('font-size', `${_r * 0.040}px`)
|
||||||
|
.attr('class', 'nw-rx')
|
||||||
|
.text('℞');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate from ASC → final position (staggered)
|
||||||
|
// circle uses cx/cy; text uses x/y — must be separate transitions.
|
||||||
|
const interpAngle = d3.interpolate(ascAngle, finalA);
|
||||||
|
const transition = () => d3.transition()
|
||||||
|
.delay(idx * 40)
|
||||||
|
.duration(600)
|
||||||
|
.ease(d3.easeQuadOut);
|
||||||
|
|
||||||
|
circle.transition(transition())
|
||||||
|
.attrTween('cx', () => t => _cx + R.planetR * Math.cos(interpAngle(t)))
|
||||||
|
.attrTween('cy', () => t => _cy + R.planetR * Math.sin(interpAngle(t)));
|
||||||
|
|
||||||
|
label.transition(transition())
|
||||||
|
.attrTween('x', () => t => _cx + R.planetR * Math.cos(interpAngle(t)))
|
||||||
|
.attrTween('y', () => t => _cy + R.planetR * Math.sin(interpAngle(t)));
|
||||||
|
|
||||||
|
// Retrograde ℞ — move together with planet
|
||||||
|
if (pdata.retrograde) {
|
||||||
|
planetGroup.select('.nw-rx:last-child')
|
||||||
|
.transition(transition())
|
||||||
|
.attrTween('x', () => t => _cx + (R.planetR + _r * 0.055) * Math.cos(interpAngle(t)))
|
||||||
|
.attrTween('y', () => t => _cy + (R.planetR + _r * 0.055) * Math.sin(interpAngle(t)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _drawAspects(g, data) {
|
||||||
|
const asc = data.houses.asc;
|
||||||
|
const aspectGroup = g.append('g').attr('class', 'nw-aspects');
|
||||||
|
|
||||||
|
// Build degree lookup
|
||||||
|
const degrees = {};
|
||||||
|
Object.entries(data.planets).forEach(([name, p]) => { degrees[name] = p.degree; });
|
||||||
|
|
||||||
|
data.aspects.forEach(({ planet1, planet2, type }) => {
|
||||||
|
if (degrees[planet1] === undefined || degrees[planet2] === undefined) return;
|
||||||
|
const a1 = _toAngle(degrees[planet1], asc);
|
||||||
|
const a2 = _toAngle(degrees[planet2], asc);
|
||||||
|
aspectGroup.append('line')
|
||||||
|
.attr('x1', _cx + R.aspectR * Math.cos(a1))
|
||||||
|
.attr('y1', _cy + R.aspectR * Math.sin(a1))
|
||||||
|
.attr('x2', _cx + R.aspectR * Math.cos(a2))
|
||||||
|
.attr('y2', _cy + R.aspectR * Math.sin(a2))
|
||||||
|
.attr('stroke', ASPECT_COLORS[type] || '#888')
|
||||||
|
.attr('stroke-width', type === 'Opposition' || type === 'Square' ? 1.2 : 0.8);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _drawElements(g, data) {
|
||||||
|
const el = data.elements;
|
||||||
|
const total = (el.Fire || 0) + (el.Stone || 0) + (el.Air || 0) + (el.Water || 0);
|
||||||
|
if (total === 0) return;
|
||||||
|
|
||||||
|
const pieData = ['Fire', 'Stone', 'Air', 'Water'].map(k => ({
|
||||||
|
key: k, value: el[k] || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pie = d3.pie().value(d => d.value).sort(null)(pieData);
|
||||||
|
const arc = d3.arc().innerRadius(R.elementInner).outerRadius(R.elementOuter);
|
||||||
|
|
||||||
|
const elGroup = g.append('g')
|
||||||
|
.attr('class', 'nw-elements')
|
||||||
|
.attr('transform', `translate(${_cx},${_cy})`);
|
||||||
|
|
||||||
|
elGroup.selectAll('path')
|
||||||
|
.data(pie)
|
||||||
|
.join('path')
|
||||||
|
.attr('d', arc)
|
||||||
|
.attr('class', d => `nw-element--${d.data.key.toLowerCase()}`);
|
||||||
|
|
||||||
|
// Time + Space emergent counts as text
|
||||||
|
['Time', 'Space'].forEach((key, i) => {
|
||||||
|
const count = el[key] || 0;
|
||||||
|
if (count === 0) return;
|
||||||
|
g.append('text')
|
||||||
|
.attr('x', _cx + (i === 0 ? -1 : 1) * R.elementInner * 0.6)
|
||||||
|
.attr('y', _cy)
|
||||||
|
.attr('text-anchor', 'middle')
|
||||||
|
.attr('dominant-baseline', 'middle')
|
||||||
|
.attr('font-size', `${_r * 0.045}px`)
|
||||||
|
.attr('class', `nw-element-label--${key.toLowerCase()}`)
|
||||||
|
.text(`${key[0]}${count}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preload zodiac sign SVGs from `basePath` (default: same dir as this script).
|
||||||
|
* Returns a Promise that resolves when all 12 are cached in _signPaths.
|
||||||
|
* Safe to call multiple times; re-fetches every call so swapped files are picked up.
|
||||||
|
*
|
||||||
|
* To swap a sign icon: replace the corresponding .svg file in zodiac-signs/
|
||||||
|
* and call NatusWheel.preload() before the next draw().
|
||||||
|
*/
|
||||||
|
async function preload(basePath) {
|
||||||
|
const base = basePath ||
|
||||||
|
(() => {
|
||||||
|
const scripts = document.querySelectorAll('script[src]');
|
||||||
|
for (const s of scripts) {
|
||||||
|
if (s.src.includes('natus-wheel')) {
|
||||||
|
return s.src.replace(/natus-wheel\.js.*$/, 'icons/zodiac-signs/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '/static/apps/gameboard/icons/zodiac-signs/';
|
||||||
|
})();
|
||||||
|
|
||||||
|
await Promise.all(SIGNS.map(async sign => {
|
||||||
|
const url = base + sign.name.toLowerCase() + '.svg';
|
||||||
|
const resp = await window.fetch(url);
|
||||||
|
if (!resp.ok) { console.warn(`NatusWheel: failed to load ${url}`); return; }
|
||||||
|
const text = await resp.text();
|
||||||
|
const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
|
||||||
|
const path = doc.querySelector('path');
|
||||||
|
if (path) _signPaths[sign.name] = path.getAttribute('d');
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(svgEl, data) {
|
||||||
|
_svg = d3.select(svgEl);
|
||||||
|
_svg.selectAll('*').remove();
|
||||||
|
_layout(svgEl);
|
||||||
|
|
||||||
|
const g = _svg.append('g').attr('class', 'nw-root');
|
||||||
|
|
||||||
|
// Outer circle border
|
||||||
|
g.append('circle')
|
||||||
|
.attr('cx', _cx).attr('cy', _cy).attr('r', R.signOuter)
|
||||||
|
.attr('class', 'nw-outer-ring');
|
||||||
|
|
||||||
|
// Inner filled disc (aspect area background)
|
||||||
|
g.append('circle')
|
||||||
|
.attr('cx', _cx).attr('cy', _cy).attr('r', R.elementOuter)
|
||||||
|
.attr('class', 'nw-inner-disc');
|
||||||
|
|
||||||
|
_drawAspects(g, data);
|
||||||
|
_drawElements(g, data);
|
||||||
|
_drawHouses(g, data);
|
||||||
|
_drawSigns(g, data);
|
||||||
|
_drawAscMc(g, data);
|
||||||
|
_drawPlanets(g, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redraw(data) {
|
||||||
|
if (!_svg) return;
|
||||||
|
const svgNode = _svg.node();
|
||||||
|
draw(svgNode, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
if (_svg) _svg.selectAll('*').remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { preload, draw, redraw, clear };
|
||||||
|
})();
|
||||||
@@ -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'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||