Compare commits
26 Commits
d4518a0671
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3800c5bdad | ||
|
|
12d575a84b | ||
|
|
c14b6d7062 | ||
|
|
a7c5468cbc | ||
|
|
4da8750c60 | ||
|
|
cf40f626e6 | ||
|
|
99a826f6c9 | ||
|
|
51fe2614fa | ||
|
|
56dc094b45 | ||
|
|
520fdf7862 | ||
|
|
e2cc38686f | ||
|
|
0bcc7567bb | ||
|
|
6654785f25 | ||
|
|
99a69202b9 | ||
|
|
55bb450d27 | ||
|
|
e28d55ad58 | ||
|
|
b110bb6d01 | ||
|
|
2892b51101 | ||
|
|
871e94b298 | ||
|
|
c3ab78cc57 | ||
|
|
c7370bda03 | ||
|
|
a15d91dfe6 | ||
|
|
fecb1fddca | ||
|
|
2028f1a544 | ||
|
|
40c747a837 | ||
|
|
40a55721ab |
@@ -32,11 +32,22 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||
await self.channel_layer.group_discard(self.cursor_group, self.channel_name)
|
||||
|
||||
async def receive_json(self, content):
|
||||
if content.get("type") == "cursor_move" and self.cursor_group:
|
||||
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):
|
||||
@@ -61,5 +72,11 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||
async def sig_selected(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def sig_hover(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def sig_reserved(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def cursor_move(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Data migration: rename/update six Earthman Major Arcana cards.
|
||||
|
||||
13 name: "Death" → "King Death & the Cosmic Tree"
|
||||
14 name: "The Traitor" → "The Great Hunt"
|
||||
15 correspondence: "The Tower / La Torre" → "The House of the Devil / Inferno"
|
||||
16 correspondence: "Purgatorio" → "The Tower / La Torre / Purgatorio"
|
||||
50 name/slug: "The Eagle" → "The Mould of Man"
|
||||
51 name/slug: "Divine Calculus" → "The Eagle"
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
FORWARD = {
|
||||
13: dict(name="King Death & the Cosmic Tree", slug="king-death-and-the-cosmic-tree"),
|
||||
14: dict(name="The Great Hunt", slug="the-great-hunt"),
|
||||
15: dict(correspondence="The House of the Devil / Inferno"),
|
||||
16: dict(correspondence="The Tower / La Torre / Purgatorio"),
|
||||
50: dict(name="The Mould of Man", slug="the-mould-of-man"),
|
||||
51: dict(name="The Eagle", slug="the-eagle"),
|
||||
}
|
||||
|
||||
REVERSE = {
|
||||
13: dict(name="Death", slug="death-em"),
|
||||
14: dict(name="The Traitor", slug="the-traitor"),
|
||||
15: dict(correspondence="The Tower / La Torre"),
|
||||
16: dict(correspondence="Purgatorio"),
|
||||
50: dict(name="The Eagle", slug="the-eagle"),
|
||||
51: dict(name="Divine Calculus",slug="divine-calculus"),
|
||||
}
|
||||
|
||||
|
||||
def apply(changes):
|
||||
def fn(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
# Process in sorted order so card 50 vacates "the-eagle" slug before card 51 claims it.
|
||||
for number in sorted(changes):
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=number
|
||||
).update(**changes[number])
|
||||
return fn
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0020_rename_earthman_pope_cards_2_5"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(apply(FORWARD), reverse_code=apply(REVERSE)),
|
||||
]
|
||||
31
src/apps/epic/migrations/0022_sig_reservation.py
Normal file
31
src/apps/epic/migrations/0022_sig_reservation.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 6.0 on 2026-04-06 00:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0021_rename_earthman_major_arcana_batch_2'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SigReservation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(max_length=2)),
|
||||
('polarity', models.CharField(choices=[('levity', 'Levity'), ('gravity', 'Gravity')], max_length=7)),
|
||||
('reserved_at', models.DateTimeField(auto_now_add=True)),
|
||||
('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to='epic.tarotcard')),
|
||||
('gamer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to=settings.AUTH_USER_MODEL)),
|
||||
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to='epic.room')),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(fields=('room', 'gamer'), name='one_sig_reservation_per_gamer_per_room'), models.UniqueConstraint(fields=('room', 'card', 'polarity'), name='one_reservation_per_card_per_polarity_per_room')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0 on 2026-04-06 02:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0022_sig_reservation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tarotcard',
|
||||
name='icon',
|
||||
field=models.CharField(blank=True, default='', max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tarotcard',
|
||||
name='arcana',
|
||||
field=models.CharField(choices=[('MAJOR', 'Major Arcana'), ('MINOR', 'Minor Arcana'), ('MIDDLE', 'Middle Arcana')], max_length=6),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tarotcard',
|
||||
name='suit',
|
||||
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('CROWNS', 'Crowns')], max_length=10, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Data migration: rename Earthman 4th-suit cards from PENTACLES → CROWNS.
|
||||
|
||||
Updates for every Earthman card where suit="PENTACLES":
|
||||
- suit: "PENTACLES" → "CROWNS"
|
||||
- name: " of Pentacles" → " of Crowns"
|
||||
- slug: "pentacles" → "crowns"
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def pentacles_to_crowns(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
for card in TarotCard.objects.filter(deck_variant=earthman, suit="PENTACLES"):
|
||||
card.suit = "CROWNS"
|
||||
card.name = card.name.replace(" of Pentacles", " of Crowns")
|
||||
card.slug = card.slug.replace("pentacles", "crowns")
|
||||
card.save(update_fields=["suit", "name", "slug"])
|
||||
|
||||
|
||||
def crowns_to_pentacles(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
for card in TarotCard.objects.filter(deck_variant=earthman, suit="CROWNS"):
|
||||
card.suit = "PENTACLES"
|
||||
card.name = card.name.replace(" of Crowns", " of Pentacles")
|
||||
card.slug = card.slug.replace("crowns", "pentacles")
|
||||
card.save(update_fields=["suit", "name", "slug"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0023_tarotcard_icon_alter_tarotcard_arcana_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(pentacles_to_crowns, reverse_code=crowns_to_pentacles),
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Data migration: Earthman deck — court cards and major arcana icons.
|
||||
|
||||
1. Court cards (numbers 11–14, all suits): arcana "MINOR" → "MIDDLE"
|
||||
2. Major arcana icons (stored in TarotCard.icon):
|
||||
0 (Nomad) → fa-hat-cowboy-side
|
||||
1 (Schizo) → fa-hat-wizard
|
||||
2–51 (rest) → fa-hand-dots
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
MAJOR_ICONS = {
|
||||
0: "fa-hat-cowboy-side",
|
||||
1: "fa-hat-wizard",
|
||||
}
|
||||
DEFAULT_MAJOR_ICON = "fa-hand-dots"
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
# Court cards → MIDDLE
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MINOR", number__in=[11, 12, 13, 14]
|
||||
).update(arcana="MIDDLE")
|
||||
|
||||
# Major arcana icons
|
||||
for card in TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR"):
|
||||
card.icon = MAJOR_ICONS.get(card.number, DEFAULT_MAJOR_ICON)
|
||||
card.save(update_fields=["icon"])
|
||||
|
||||
|
||||
def backward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MIDDLE", number__in=[11, 12, 13, 14]
|
||||
).update(arcana="MINOR")
|
||||
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR"
|
||||
).update(icon="")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0024_earthman_pentacles_to_crowns"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse_code=backward),
|
||||
]
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Data migration — Earthman deck:
|
||||
1. Rename three suit codes (and card names) for Earthman cards:
|
||||
WANDS → BRANDS (Wands → Brands)
|
||||
CUPS → GRAILS (Cups → Grails)
|
||||
SWORDS → BLADES (Swords → Blades)
|
||||
CROWNS stays CROWNS.
|
||||
2. Copy keywords_upright / keywords_reversed from the Fiorentine Minchiate
|
||||
deck to corresponding Earthman cards:
|
||||
• Major: explicit number-to-number map based on card correspondences.
|
||||
• Minor/Middle: same number, suit mapped (BRANDS→WANDS, GRAILS→CUPS,
|
||||
BLADES→SWORDS, CROWNS→PENTACLES). Cards with no Fiorentine counterpart
|
||||
stay with empty keyword lists.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
# ── 1. Suit rename map ────────────────────────────────────────────────────────
|
||||
|
||||
SUIT_RENAMES = {
|
||||
"WANDS": "BRANDS",
|
||||
"CUPS": "GRAILS",
|
||||
"SWORDS": "BLADES",
|
||||
}
|
||||
|
||||
# ── 2. Major arcana: Earthman number → Fiorentine number ─────────────────────
|
||||
# Cards without a Fiorentine counterpart are omitted (keywords stay empty).
|
||||
|
||||
MAJOR_KEYWORD_MAP = {
|
||||
0: 0, # The Schiz → The Fool
|
||||
1: 1, # Pope I (President) → The Magician
|
||||
2: 2, # Pope II (Tsar) → The High Priestess
|
||||
3: 3, # Pope III (Chairman) → The Empress
|
||||
4: 4, # Pope IV (Emperor) → The Emperor
|
||||
5: 5, # Pope V (Chancellor) → The Hierophant
|
||||
6: 8, # Virtue VI (Controlled Folly) → Strength
|
||||
7: 11, # Virtue VII (Not-Doing) → Justice
|
||||
8: 14, # Virtue VIII (Losing Self-Importance) → Temperance
|
||||
# 9: Prudence — no Fiorentine equivalent
|
||||
10: 10, # Wheel of Fortune → Wheel of Fortune
|
||||
11: 7, # The Junkboat → The Chariot
|
||||
12: 12, # The Junkman → The Hanged Man
|
||||
13: 13, # Death → Death
|
||||
14: 15, # The Traitor → The Devil
|
||||
15: 16, # Disco Inferno → The Tower
|
||||
# 16: Torre Terrestre (Purgatory) — no equivalent
|
||||
# 17: Fantasia Celestia (Paradise) — no equivalent
|
||||
18: 6, # Virtue XVIII (Stalking) → The Lovers
|
||||
# 19: Virtue XIX (Intent / Hope) — no equivalent
|
||||
# 20: Virtue XX (Dreaming / Faith)— no equivalent
|
||||
# 21–38: Classical Elements + Zodiac — no equivalents
|
||||
39: 17, # Wanderer XXXIX (Polestar) → The Star
|
||||
40: 18, # Wanderer XL (Antichthon) → The Moon
|
||||
41: 19, # Wanderer XLI (Corestar) → The Sun
|
||||
# 42–49: Planets + The Binary — no equivalents
|
||||
50: 20, # The Eagle → Judgement
|
||||
51: 21, # Divine Calculus → The World
|
||||
}
|
||||
|
||||
# ── 3. Minor suit map: Earthman (post-rename) → Fiorentine ───────────────────
|
||||
|
||||
MINOR_SUIT_MAP = {
|
||||
"BRANDS": "WANDS",
|
||||
"GRAILS": "CUPS",
|
||||
"BLADES": "SWORDS",
|
||||
"CROWNS": "PENTACLES",
|
||||
}
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return # decks not seeded — nothing to do
|
||||
|
||||
# ── Step 1: rename Earthman suit codes + card names ───────────────────────
|
||||
for old_suit, new_suit in SUIT_RENAMES.items():
|
||||
old_display = old_suit.capitalize() # e.g. "Wands"
|
||||
new_display = new_suit.capitalize() # e.g. "Brands"
|
||||
cards = TarotCard.objects.filter(deck_variant=earthman, suit=old_suit)
|
||||
for card in cards:
|
||||
card.name = card.name.replace(f" of {old_display}", f" of {new_display}")
|
||||
card.suit = new_suit
|
||||
card.save()
|
||||
|
||||
# ── Step 2: copy major arcana keywords ───────────────────────────────────
|
||||
fio_major = {
|
||||
card.number: card
|
||||
for card in TarotCard.objects.filter(deck_variant=fiorentine, arcana="MAJOR")
|
||||
}
|
||||
for em_num, fio_num in MAJOR_KEYWORD_MAP.items():
|
||||
fio_card = fio_major.get(fio_num)
|
||||
if not fio_card:
|
||||
continue
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=em_num
|
||||
).update(
|
||||
keywords_upright=fio_card.keywords_upright,
|
||||
keywords_reversed=fio_card.keywords_reversed,
|
||||
)
|
||||
|
||||
# ── Step 3: copy minor/middle arcana keywords ─────────────────────────────
|
||||
for em_suit, fio_suit in MINOR_SUIT_MAP.items():
|
||||
fio_by_number = {
|
||||
card.number: card
|
||||
for card in TarotCard.objects.filter(deck_variant=fiorentine, suit=fio_suit)
|
||||
}
|
||||
for em_card in TarotCard.objects.filter(deck_variant=earthman, suit=em_suit):
|
||||
fio_card = fio_by_number.get(em_card.number)
|
||||
if fio_card:
|
||||
em_card.keywords_upright = fio_card.keywords_upright
|
||||
em_card.keywords_reversed = fio_card.keywords_reversed
|
||||
em_card.save()
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
|
||||
# Reverse suit renames
|
||||
reverse_renames = {new: old for old, new in SUIT_RENAMES.items()}
|
||||
for new_suit, old_suit in reverse_renames.items():
|
||||
new_display = new_suit.capitalize()
|
||||
old_display = old_suit.capitalize()
|
||||
cards = TarotCard.objects.filter(deck_variant=earthman, suit=new_suit)
|
||||
for card in cards:
|
||||
card.name = card.name.replace(f" of {new_display}", f" of {old_display}")
|
||||
card.suit = old_suit
|
||||
card.save()
|
||||
|
||||
# Clear all Earthman keywords
|
||||
TarotCard.objects.filter(deck_variant=earthman).update(
|
||||
keywords_upright=[],
|
||||
keywords_reversed=[],
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0025_earthman_middle_arcana_and_major_icons"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse_code=reverse),
|
||||
]
|
||||
65
src/apps/epic/migrations/0027_tarotcard_cautions.py
Normal file
65
src/apps/epic/migrations/0027_tarotcard_cautions.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Schema + data migration:
|
||||
1. Add `cautions` JSONField (list, default=[]) to TarotCard.
|
||||
2. Seed The Schizo (Earthman MAJOR #1) with 4 rival-interaction cautions.
|
||||
All other cards default to [] — the UI shows a placeholder when empty.
|
||||
"""
|
||||
from django.db import migrations, models
|
||||
|
||||
SCHIZO_CAUTIONS = [
|
||||
'This card will reverse into <span class="card-ref">The Pervert</span> when it'
|
||||
' comes under dominion of <span class="card-ref">The Occultist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">Pestilence</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">The Paranoiac</span> when it'
|
||||
' comes under dominion of <span class="card-ref">The Despot</span>, which in turn'
|
||||
' reverses into <span class="card-ref">War</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">The Neurotic</span> when it'
|
||||
' comes under dominion of <span class="card-ref">The Capitalist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">Famine</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">The Suicidal</span> when it'
|
||||
' comes under dominion of <span class="card-ref">The Fascist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">Death</span>.',
|
||||
]
|
||||
|
||||
|
||||
def seed_schizo_cautions(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=1
|
||||
).update(cautions=SCHIZO_CAUTIONS)
|
||||
|
||||
|
||||
def clear_schizo_cautions(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=1
|
||||
).update(cautions=[])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0026_earthman_suit_renames_and_keywords"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="tarotcard",
|
||||
name="cautions",
|
||||
field=models.JSONField(default=list),
|
||||
),
|
||||
migrations.RunPython(seed_schizo_cautions, reverse_code=clear_schizo_cautions),
|
||||
]
|
||||
18
src/apps/epic/migrations/0028_alter_tarotcard_suit.py
Normal file
18
src/apps/epic/migrations/0028_alter_tarotcard_suit.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0 on 2026-04-07 03:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0027_tarotcard_cautions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='tarotcard',
|
||||
name='suit',
|
||||
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('CROWNS', 'Crowns'), ('BRANDS', 'Brands'), ('GRAILS', 'Grails'), ('BLADES', 'Blades')], max_length=10, null=True),
|
||||
),
|
||||
]
|
||||
61
src/apps/epic/migrations/0029_fix_schizo_cautions.py
Normal file
61
src/apps/epic/migrations/0029_fix_schizo_cautions.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Data fix: clear Schizo cautions from The Nomad (number=0) if present,
|
||||
and ensure they land on The Schizo (number=1).
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
SCHIZO_CAUTIONS = [
|
||||
'This card will reverse into <span class="card-ref">I. The Pervert</span> when it'
|
||||
' comes under dominion of <span class="card-ref">II. The Occultist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">II. Pestilence</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">I. The Paranoiac</span> when it'
|
||||
' comes under dominion of <span class="card-ref">III. The Despot</span>, which in turn'
|
||||
' reverses into <span class="card-ref">III. War</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">I. The Neurotic</span> when it'
|
||||
' comes under dominion of <span class="card-ref">IV. The Capitalist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">IV. Famine</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">I. The Suicidal</span> when it'
|
||||
' comes under dominion of <span class="card-ref">V. The Fascist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">V. Death</span>.',
|
||||
]
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=0
|
||||
).update(cautions=[])
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=1
|
||||
).update(cautions=SCHIZO_CAUTIONS)
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=1
|
||||
).update(cautions=[])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0028_alter_tarotcard_suit"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse_code=reverse),
|
||||
]
|
||||
23
src/apps/epic/migrations/0030_sigreservation_seat_fk.py
Normal file
23
src/apps/epic/migrations/0030_sigreservation_seat_fk.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0029_fix_schizo_cautions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sigreservation',
|
||||
name='seat',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='sig_reservation',
|
||||
to='epic.tableseat',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -3,6 +3,7 @@ import uuid
|
||||
|
||||
from datetime import timedelta
|
||||
from django.db import models
|
||||
from django.db.models import UniqueConstraint
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.conf import settings
|
||||
@@ -204,20 +205,30 @@ class DeckVariant(models.Model):
|
||||
class TarotCard(models.Model):
|
||||
MAJOR = "MAJOR"
|
||||
MINOR = "MINOR"
|
||||
MIDDLE = "MIDDLE" # Earthman court cards (M/J/Q/K)
|
||||
ARCANA_CHOICES = [
|
||||
(MAJOR, "Major Arcana"),
|
||||
(MINOR, "Minor Arcana"),
|
||||
(MIDDLE, "Middle Arcana"),
|
||||
]
|
||||
|
||||
WANDS = "WANDS"
|
||||
CUPS = "CUPS"
|
||||
SWORDS = "SWORDS"
|
||||
PENTACLES = "PENTACLES" # Fiorentine 4th suit
|
||||
CROWNS = "CROWNS" # Earthman 4th suit
|
||||
BRANDS = "BRANDS" # Earthman Wands
|
||||
GRAILS = "GRAILS" # Earthman Cups
|
||||
BLADES = "BLADES" # Earthman Swords
|
||||
SUIT_CHOICES = [
|
||||
(WANDS, "Wands"),
|
||||
(CUPS, "Cups"),
|
||||
(SWORDS, "Swords"),
|
||||
(PENTACLES, "Pentacles"),
|
||||
(CROWNS, "Crowns"),
|
||||
(BRANDS, "Brands"),
|
||||
(GRAILS, "Grails"),
|
||||
(BLADES, "Blades"),
|
||||
]
|
||||
|
||||
deck_variant = models.ForeignKey(
|
||||
@@ -225,14 +236,16 @@ class TarotCard(models.Model):
|
||||
on_delete=models.CASCADE, related_name="cards",
|
||||
)
|
||||
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)
|
||||
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
|
||||
slug = models.SlugField(max_length=120)
|
||||
correspondence = models.CharField(max_length=200, blank=True) # standard / Italian equivalent
|
||||
group = models.CharField(max_length=100, blank=True) # Earthman major grouping
|
||||
keywords_upright = models.JSONField(default=list)
|
||||
keywords_reversed = models.JSONField(default=list)
|
||||
cautions = models.JSONField(default=list)
|
||||
|
||||
class Meta:
|
||||
ordering = ["deck_variant", "arcana", "suit", "number"]
|
||||
@@ -274,6 +287,8 @@ class TarotCard(models.Model):
|
||||
|
||||
@property
|
||||
def suit_icon(self):
|
||||
if self.icon:
|
||||
return self.icon
|
||||
if self.arcana == self.MAJOR:
|
||||
return ''
|
||||
return {
|
||||
@@ -281,8 +296,17 @@ class TarotCard(models.Model):
|
||||
self.CUPS: 'fa-trophy',
|
||||
self.SWORDS: 'fa-gun',
|
||||
self.PENTACLES: 'fa-star',
|
||||
self.CROWNS: 'fa-crown',
|
||||
self.BRANDS: 'fa-wand-sparkles',
|
||||
self.GRAILS: 'fa-trophy',
|
||||
self.BLADES: 'fa-gun',
|
||||
}.get(self.suit, '')
|
||||
|
||||
@property
|
||||
def cautions_json(self):
|
||||
import json
|
||||
return json.dumps(self.cautions)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -324,29 +348,64 @@ class TarotDeck(models.Model):
|
||||
self.save(update_fields=["drawn_card_ids"])
|
||||
|
||||
|
||||
# ── SigReservation — provisional card hold during SIG_SELECT ──────────────────
|
||||
|
||||
class SigReservation(models.Model):
|
||||
LEVITY = 'levity'
|
||||
GRAVITY = 'gravity'
|
||||
POLARITY_CHOICES = [(LEVITY, 'Levity'), (GRAVITY, 'Gravity')]
|
||||
|
||||
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name='sig_reservations')
|
||||
gamer = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sig_reservations'
|
||||
)
|
||||
seat = models.ForeignKey(
|
||||
'TableSeat', null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name='sig_reservation',
|
||||
)
|
||||
card = models.ForeignKey(
|
||||
'TarotCard', on_delete=models.CASCADE, related_name='sig_reservations'
|
||||
)
|
||||
role = models.CharField(max_length=2)
|
||||
polarity = models.CharField(max_length=7, choices=POLARITY_CHOICES)
|
||||
reserved_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
UniqueConstraint(
|
||||
fields=['room', 'gamer'],
|
||||
name='one_sig_reservation_per_gamer_per_room',
|
||||
),
|
||||
UniqueConstraint(
|
||||
fields=['room', 'card', 'polarity'],
|
||||
name='one_reservation_per_card_per_polarity_per_room',
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ── Significator deck helpers ─────────────────────────────────────────────────
|
||||
|
||||
def sig_deck_cards(room):
|
||||
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
|
||||
|
||||
PC/BC pair → WANDS + PENTACLES court cards (numbers 11–14): 8 unique
|
||||
SC/AC pair → SWORDS + CUPS court cards (numbers 11–14): 8 unique
|
||||
PC/BC pair → BRANDS/WANDS + CROWNS Middle Arcana court cards (11–14): 8 unique
|
||||
SC/AC pair → BLADES/SWORDS + GRAILS/CUPS Middle Arcana court cards (11–14): 8 unique
|
||||
NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique
|
||||
Total: 18 unique × 2 (levity + gravity piles) = 36 cards.
|
||||
"""
|
||||
deck_variant = room.owner.equipped_deck
|
||||
if deck_variant is None:
|
||||
return []
|
||||
wands_pentacles = list(TarotCard.objects.filter(
|
||||
wands_crowns = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MINOR,
|
||||
suit__in=[TarotCard.WANDS, TarotCard.PENTACLES],
|
||||
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.MINOR,
|
||||
suit__in=[TarotCard.SWORDS, TarotCard.CUPS],
|
||||
arcana=TarotCard.MIDDLE,
|
||||
suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS],
|
||||
number__in=[11, 12, 13, 14],
|
||||
))
|
||||
major = list(TarotCard.objects.filter(
|
||||
@@ -354,10 +413,45 @@ def sig_deck_cards(room):
|
||||
arcana=TarotCard.MAJOR,
|
||||
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
|
||||
|
||||
|
||||
def _sig_unique_cards(room):
|
||||
"""Return the 18 unique TarotCard objects that form one sig pile."""
|
||||
deck_variant = room.owner.equipped_deck
|
||||
if deck_variant is None:
|
||||
return []
|
||||
wands_crowns = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MIDDLE,
|
||||
suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS],
|
||||
number__in=[11, 12, 13, 14],
|
||||
))
|
||||
swords_cups = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MIDDLE,
|
||||
suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS],
|
||||
number__in=[11, 12, 13, 14],
|
||||
))
|
||||
major = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MAJOR,
|
||||
number__in=[0, 1],
|
||||
))
|
||||
return wands_crowns + swords_cups + major
|
||||
|
||||
|
||||
def levity_sig_cards(room):
|
||||
"""The 18 cards available to the levity group (PC/NC/SC)."""
|
||||
return _sig_unique_cards(room)
|
||||
|
||||
|
||||
def gravity_sig_cards(room):
|
||||
"""The 18 cards available to the gravity group (BC/EC/AC)."""
|
||||
return _sig_unique_cards(room)
|
||||
|
||||
|
||||
def sig_seat_order(room):
|
||||
"""Return TableSeats in canonical PC→NC→EC→SC→AC→BC order."""
|
||||
_order = {r: i for i, r in enumerate(SIG_SEAT_ORDER)}
|
||||
|
||||
@@ -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 |
@@ -185,7 +185,8 @@ var RoleSelect = (function () {
|
||||
function () { // dismiss (NVM / outside click)
|
||||
card.classList.remove("guard-active");
|
||||
card.classList.remove("flipped");
|
||||
}
|
||||
},
|
||||
{ invertY: true } // modal grid: tooltip flies away from centre (upper→above, lower→below)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,92 @@
|
||||
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 () {
|
||||
@@ -27,6 +113,7 @@
|
||||
const roomId = roomPage.dataset.roomId;
|
||||
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
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) {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
@@ -1,96 +1,477 @@
|
||||
var SigSelect = (function () {
|
||||
var SIG_ORDER = ['PC', 'NC', 'EC', 'SC', 'AC', 'BC'];
|
||||
// Polarity → three roles in fixed left/mid/right cursor order
|
||||
var POLARITY_ROLES = {
|
||||
levity: ['PC', 'NC', 'SC'],
|
||||
gravity: ['BC', 'EC', 'AC'],
|
||||
};
|
||||
|
||||
var sigDeck, selectUrl, userRole;
|
||||
var overlay, deckGrid, stage, stageCard, statBlock;
|
||||
var cautionEl, cautionEffect, cautionPrev, cautionNext, cautionIndexEl;
|
||||
var _flipBtn, _cautionBtn, _flipOrigLabel, _cautionOrigLabel;
|
||||
var reserveUrl, userRole, userPolarity;
|
||||
|
||||
function getActiveRole() {
|
||||
for (var i = 0; i < SIG_ORDER.length; i++) {
|
||||
var seat = document.querySelector('.table-seat[data-role="' + SIG_ORDER[i] + '"]');
|
||||
if (seat && !seat.dataset.sigDone) return SIG_ORDER[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
var _cautionData = [];
|
||||
var _cautionIdx = 0;
|
||||
|
||||
function isEligible() {
|
||||
return !!(userRole && userRole === getActiveRole());
|
||||
}
|
||||
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] : '';
|
||||
}
|
||||
|
||||
function applySelection(cardId, role, deckType) {
|
||||
// Remove only the specific pile copy (levity or gravity) of this card
|
||||
var selector = '.sig-card.' + deckType + '-deck[data-card-id="' + cardId + '"]';
|
||||
sigDeck.querySelectorAll(selector).forEach(function (c) { c.remove(); });
|
||||
// ── Stage ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Mark this seat done, remove active
|
||||
var seat = document.querySelector('.table-seat[data-role="' + role + '"]');
|
||||
if (seat) {
|
||||
seat.classList.remove('active');
|
||||
seat.dataset.sigDone = '1';
|
||||
function _populateKeywordList(listEl, csv) {
|
||||
var keywords = csv ? csv.split(',').filter(Boolean) : [];
|
||||
listEl.innerHTML = keywords.map(function (k) {
|
||||
return '<li>' + k.trim() + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Advance active to next seat
|
||||
var nextRole = getActiveRole();
|
||||
if (nextRole) {
|
||||
var nextSeat = document.querySelector('.table-seat[data-role="' + nextRole + '"]');
|
||||
if (nextSeat) nextSeat.classList.add('active');
|
||||
// ── 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
|
||||
: '';
|
||||
}
|
||||
|
||||
// Place a card placeholder in inventory
|
||||
var invSlot = document.getElementById('id_inv_sig_card');
|
||||
if (invSlot) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
invSlot.appendChild(card);
|
||||
function _openCaution() {
|
||||
if (!_focusedCardEl) return;
|
||||
try {
|
||||
_cautionData = JSON.parse(_focusedCardEl.dataset.cautions || '[]');
|
||||
} catch (e) {
|
||||
_cautionData = [];
|
||||
}
|
||||
_cautionIdx = 0;
|
||||
_renderCaution();
|
||||
_flipBtn.classList.add('btn-disabled');
|
||||
_cautionBtn.classList.add('btn-disabled');
|
||||
_flipBtn.textContent = '\u00D7';
|
||||
_cautionBtn.textContent = '\u00D7';
|
||||
stage.classList.add('sig-caution-open');
|
||||
}
|
||||
|
||||
function _closeCaution() {
|
||||
stage.classList.remove('sig-caution-open');
|
||||
if (_flipBtn) {
|
||||
_flipBtn.classList.remove('btn-disabled');
|
||||
_cautionBtn.classList.remove('btn-disabled');
|
||||
_flipBtn.textContent = _flipOrigLabel;
|
||||
_cautionBtn.textContent = _cautionOrigLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStage(cardEl) {
|
||||
if (_stageFrozen) return;
|
||||
_closeCaution();
|
||||
if (!cardEl) {
|
||||
stageCard.style.display = 'none';
|
||||
stage.classList.remove('sig-stage--active');
|
||||
_focusedCardEl = null;
|
||||
return;
|
||||
}
|
||||
_focusedCardEl = cardEl;
|
||||
|
||||
var rank = cardEl.dataset.cornerRank || '';
|
||||
var icon = cardEl.dataset.suitIcon || '';
|
||||
var group = cardEl.dataset.nameGroup || '';
|
||||
var title = cardEl.dataset.nameTitle || '';
|
||||
var arcana= cardEl.dataset.arcana || '';
|
||||
var corr = cardEl.dataset.correspondence || '';
|
||||
|
||||
stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) { el.textContent = rank; });
|
||||
stageCard.querySelectorAll('.stage-suit-icon').forEach(function (el) {
|
||||
if (icon) {
|
||||
el.className = 'fa-solid ' + icon + ' stage-suit-icon';
|
||||
el.style.display = '';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
});
|
||||
stageCard.querySelector('.fan-card-name-group').textContent = group;
|
||||
stageCard.querySelector('.fan-card-arcana').textContent = arcana;
|
||||
stageCard.querySelector('.fan-card-correspondence').textContent = ''; // shown in game-kit only
|
||||
|
||||
var qualifier = userPolarity === 'levity' ? 'Leavened' : 'Graven';
|
||||
var isMajor = arcana.toLowerCase().indexOf('major') !== -1;
|
||||
// Major arcana: qualifier sits below the title — append comma so it reads as a subtitle.
|
||||
stageCard.querySelector('.fan-card-name').textContent = isMajor ? title + ',' : title;
|
||||
stageCard.querySelector('.sig-qualifier-above').textContent = isMajor ? '' : qualifier;
|
||||
stageCard.querySelector('.sig-qualifier-below').textContent = isMajor ? qualifier : '';
|
||||
|
||||
// Populate stat block keyword faces and reset to upright
|
||||
statBlock.classList.remove('is-reversed');
|
||||
_populateKeywordList(
|
||||
statBlock.querySelector('#id_stat_keywords_upright'),
|
||||
cardEl.dataset.keywordsUpright
|
||||
);
|
||||
_populateKeywordList(
|
||||
statBlock.querySelector('#id_stat_keywords_reversed'),
|
||||
cardEl.dataset.keywordsReversed
|
||||
);
|
||||
|
||||
stageCard.style.display = '';
|
||||
stage.classList.add('sig-stage--active');
|
||||
}
|
||||
|
||||
// ── Focus a card (click/tap) — shows OK overlay on the card ──────────
|
||||
|
||||
function focusCard(cardEl) {
|
||||
deckGrid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) {
|
||||
if (c !== cardEl) c.classList.remove('sig-focused');
|
||||
});
|
||||
cardEl.classList.add('sig-focused');
|
||||
updateStage(cardEl);
|
||||
}
|
||||
|
||||
// ── Hover events ──────────────────────────────────────────────────────
|
||||
|
||||
function onCardEnter(e) {
|
||||
var card = e.currentTarget;
|
||||
if (!_stageFrozen) updateStage(card);
|
||||
sendHover(card.dataset.cardId, true);
|
||||
}
|
||||
|
||||
function onCardLeave(e) {
|
||||
if (!_stageFrozen) updateStage(null);
|
||||
sendHover(e.currentTarget.dataset.cardId, false);
|
||||
}
|
||||
|
||||
// ── Reserve / release ─────────────────────────────────────────────────
|
||||
|
||||
function doReserve(cardEl) {
|
||||
if (_requestInFlight) return;
|
||||
var cardId = cardEl.dataset.cardId;
|
||||
_requestInFlight = true;
|
||||
fetch(reserveUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||
body: 'action=reserve&card_id=' + encodeURIComponent(cardId),
|
||||
}).then(function (res) {
|
||||
_requestInFlight = false;
|
||||
if (res.ok) applyReservation(cardId, userRole, true);
|
||||
}).catch(function () { _requestInFlight = false; });
|
||||
}
|
||||
|
||||
function doRelease() {
|
||||
if (_requestInFlight || !_reservedCardId) return;
|
||||
var cardId = _reservedCardId;
|
||||
_requestInFlight = true;
|
||||
fetch(reserveUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||
body: 'action=release&card_id=' + encodeURIComponent(cardId),
|
||||
}).then(function (res) {
|
||||
_requestInFlight = false;
|
||||
if (res.ok) applyReservation(cardId, userRole, false);
|
||||
}).catch(function () { _requestInFlight = false; });
|
||||
}
|
||||
|
||||
// ── Apply reservation state (local + from WS) ─────────────────────────
|
||||
|
||||
function _placeReservedFloat(cardId, cardEl, role) {
|
||||
// Remove any pre-existing reserved float for this role (e.g. page-load replay)
|
||||
if (_reservedFloats[role]) { _reservedFloats[role].remove(); }
|
||||
|
||||
// Retire ALL hover floats for this role — may be on a different card than reserved
|
||||
var roles = POLARITY_ROLES[userPolarity] || [];
|
||||
var idx = roles.indexOf(role);
|
||||
var posClass = ['--left', '--mid', '--right'][idx] || '--left';
|
||||
Object.keys(_floatingCursors).forEach(function (key) {
|
||||
if (key.slice(-posClass.length) === posClass) {
|
||||
_floatingCursors[key].remove();
|
||||
var hCid = key.slice(0, key.length - posClass.length);
|
||||
var hEl = deckGrid.querySelector('.sig-card[data-card-id="' + hCid + '"]');
|
||||
if (hEl) {
|
||||
var a = hEl.querySelector('.sig-cursor' + posClass);
|
||||
if (a) a.classList.remove('active');
|
||||
}
|
||||
delete _floatingCursors[key];
|
||||
}
|
||||
});
|
||||
|
||||
var rect = cardEl.getBoundingClientRect();
|
||||
var xFractions = [0.15, 0.5, 0.85];
|
||||
var fc = document.createElement('i');
|
||||
fc.className = 'fa-solid fa-thumbs-up sig-cursor-float sig-cursor-float--reserved';
|
||||
fc.dataset.role = role;
|
||||
fc.style.left = (rect.left + rect.width * xFractions[idx < 0 ? 1 : idx]) + 'px';
|
||||
fc.style.top = rect.bottom + 'px';
|
||||
_ensureCursorPortal().appendChild(fc);
|
||||
_reservedFloats[role] = fc;
|
||||
}
|
||||
|
||||
function applyReservation(cardId, role, reserved) {
|
||||
var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]');
|
||||
if (!cardEl) return;
|
||||
|
||||
if (reserved) {
|
||||
cardEl.dataset.reservedBy = role;
|
||||
cardEl.classList.add('sig-reserved');
|
||||
if (role === userRole) {
|
||||
_reservedCardId = cardId;
|
||||
cardEl.classList.add('sig-reserved--own');
|
||||
cardEl.classList.remove('sig-focused');
|
||||
// Freeze stage on this card (temporarily unfreeze to populate it)
|
||||
_stageFrozen = false;
|
||||
updateStage(cardEl);
|
||||
_stageFrozen = true;
|
||||
stage.classList.add('sig-stage--frozen');
|
||||
}
|
||||
// Thumbs-up float for all reservations — own role sees their own indicator too
|
||||
_placeReservedFloat(cardId, cardEl, role);
|
||||
} else {
|
||||
delete cardEl.dataset.reservedBy;
|
||||
cardEl.classList.remove('sig-reserved', 'sig-reserved--own');
|
||||
if (role === userRole) {
|
||||
_reservedCardId = null;
|
||||
_stageFrozen = false;
|
||||
stage.classList.remove('sig-stage--frozen');
|
||||
}
|
||||
// Remove thumbs-up float for all releases — own role included
|
||||
if (_reservedFloats[role]) {
|
||||
_reservedFloats[role].remove();
|
||||
delete _reservedFloats[role];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Apply hover cursor (WS only — own hover is CSS :hover) ────────────
|
||||
//
|
||||
// Cursor icons are portaled to document root so they escape overflow/clip
|
||||
// contexts in the deck grid. The in-card anchor elements only carry the
|
||||
// .active class (for test assertions and the :has() z-index rule).
|
||||
|
||||
function _ensureCursorPortal() {
|
||||
if (!_cursorPortal || !document.body.contains(_cursorPortal)) {
|
||||
_cursorPortal = document.getElementById('id_sig_cursor_portal');
|
||||
if (!_cursorPortal) {
|
||||
_cursorPortal = document.createElement('div');
|
||||
_cursorPortal.id = 'id_sig_cursor_portal';
|
||||
document.body.appendChild(_cursorPortal);
|
||||
}
|
||||
}
|
||||
return _cursorPortal;
|
||||
}
|
||||
|
||||
function applyHover(cardId, role, active) {
|
||||
if (role === userRole) return;
|
||||
if (_reservedFloats[role]) return; // role already has thumbs-up — ignore hover
|
||||
var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]');
|
||||
if (!cardEl) return;
|
||||
|
||||
var roles = POLARITY_ROLES[userPolarity] || [];
|
||||
var idx = roles.indexOf(role);
|
||||
var posClass = ['--left', '--mid', '--right'][idx] || '--left';
|
||||
var anchor = cardEl.querySelector('.sig-cursor' + posClass);
|
||||
if (!anchor) return;
|
||||
|
||||
var key = cardId + posClass;
|
||||
|
||||
if (active) {
|
||||
anchor.classList.add('active'); // kept for test assertions + :has() z-index
|
||||
|
||||
// Place a fixed-position clone in the portal, positioned from card bounds
|
||||
var rect = cardEl.getBoundingClientRect();
|
||||
var xFractions = [0.15, 0.5, 0.85];
|
||||
var fc = document.createElement('i');
|
||||
fc.className = 'fa-solid fa-hand-pointer sig-cursor-float';
|
||||
fc.dataset.role = role;
|
||||
fc.style.left = (rect.left + rect.width * xFractions[idx]) + 'px';
|
||||
fc.style.top = rect.bottom + 'px';
|
||||
_ensureCursorPortal().appendChild(fc);
|
||||
_floatingCursors[key] = fc;
|
||||
} else {
|
||||
anchor.classList.remove('active');
|
||||
if (_floatingCursors[key]) {
|
||||
_floatingCursors[key].remove();
|
||||
delete _floatingCursors[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── WS events ─────────────────────────────────────────────────────────
|
||||
|
||||
window.addEventListener('room:sig_reserved', function (e) {
|
||||
if (!deckGrid) return;
|
||||
applyReservation(String(e.detail.card_id), e.detail.role, e.detail.reserved);
|
||||
});
|
||||
|
||||
window.addEventListener('room:sig_hover', function (e) {
|
||||
if (!deckGrid) return;
|
||||
applyHover(String(e.detail.card_id), e.detail.role, e.detail.active);
|
||||
});
|
||||
|
||||
// ── WS send ───────────────────────────────────────────────────────────
|
||||
|
||||
function sendHover(cardId, active) {
|
||||
if (!window._roomSocket || window._roomSocket.readyState !== WebSocket.OPEN) return;
|
||||
window._roomSocket.send(JSON.stringify({
|
||||
type: 'sig_hover', card_id: cardId, role: userRole, active: active,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
sigDeck = document.getElementById('id_sig_deck');
|
||||
if (!sigDeck) return;
|
||||
selectUrl = sigDeck.dataset.selectSigUrl;
|
||||
userRole = sigDeck.dataset.userRole;
|
||||
overlay = document.querySelector('.sig-overlay');
|
||||
if (!overlay) return;
|
||||
|
||||
sigDeck.addEventListener('click', function (e) {
|
||||
deckGrid = overlay.querySelector('.sig-deck-grid');
|
||||
stage = overlay.querySelector('.sig-stage');
|
||||
stageCard = stage.querySelector('.sig-stage-card');
|
||||
statBlock = stage.querySelector('.sig-stat-block');
|
||||
|
||||
_flipBtn = statBlock.querySelector('.sig-flip-btn');
|
||||
_cautionBtn = statBlock.querySelector('.sig-caution-btn');
|
||||
_flipOrigLabel = _flipBtn.textContent;
|
||||
_cautionOrigLabel = _cautionBtn.textContent;
|
||||
|
||||
_flipBtn.addEventListener('click', function () {
|
||||
if (_flipBtn.classList.contains('btn-disabled')) return;
|
||||
statBlock.classList.toggle('is-reversed');
|
||||
});
|
||||
|
||||
cautionEl = stage.querySelector('.sig-caution-tooltip');
|
||||
cautionEffect = cautionEl.querySelector('.sig-caution-effect');
|
||||
cautionPrev = statBlock.querySelector('.sig-caution-prev');
|
||||
cautionNext = statBlock.querySelector('.sig-caution-next');
|
||||
cautionIndexEl = cautionEl.querySelector('.sig-caution-index');
|
||||
|
||||
// Clicking the tooltip (not nav buttons) dismisses it
|
||||
cautionEl.addEventListener('click', function () {
|
||||
_closeCaution();
|
||||
});
|
||||
|
||||
_cautionBtn.addEventListener('click', function () {
|
||||
if (_cautionBtn.classList.contains('btn-disabled')) return;
|
||||
stage.classList.contains('sig-caution-open') ? _closeCaution() : _openCaution();
|
||||
});
|
||||
cautionPrev.addEventListener('click', function () {
|
||||
_cautionIdx = (_cautionIdx - 1 + _cautionData.length) % _cautionData.length;
|
||||
_renderCaution();
|
||||
});
|
||||
cautionNext.addEventListener('click', function () {
|
||||
_cautionIdx = (_cautionIdx + 1) % _cautionData.length;
|
||||
_renderCaution();
|
||||
});
|
||||
|
||||
reserveUrl = overlay.dataset.reserveUrl;
|
||||
userRole = overlay.dataset.userRole;
|
||||
userPolarity= overlay.dataset.polarity;
|
||||
|
||||
// Restore reservations from server-rendered JSON (page-load state).
|
||||
// Deferred to 'load' so sizeSigModal() (also a 'load' listener, registered
|
||||
// in room.js before this script) has already applied paddingBottom and
|
||||
// --sig-card-w before _placeReservedFloat calls getBoundingClientRect().
|
||||
try {
|
||||
var existing = JSON.parse(overlay.dataset.reservations || '{}');
|
||||
if (Object.keys(existing).length) {
|
||||
var _replayReservations = function () {
|
||||
Object.keys(existing).forEach(function (cardId) {
|
||||
applyReservation(cardId, existing[cardId], true);
|
||||
});
|
||||
};
|
||||
if (document.readyState === 'complete') {
|
||||
_replayReservations();
|
||||
} else {
|
||||
window.addEventListener('load', _replayReservations, { once: true });
|
||||
}
|
||||
}
|
||||
} catch (e) { /* malformed JSON — ignore */ }
|
||||
|
||||
// Hover: update stage preview + broadcast cursor
|
||||
deckGrid.querySelectorAll('.sig-card').forEach(function (card) {
|
||||
card.addEventListener('mouseenter', onCardEnter);
|
||||
card.addEventListener('mouseleave', onCardLeave);
|
||||
card.addEventListener('touchstart', function (e) {
|
||||
var card = e.currentTarget;
|
||||
if (_reservedCardId) return; // locked until NVM — no preventDefault either
|
||||
var reservedByOther = card.dataset.reservedBy && card.dataset.reservedBy !== userRole;
|
||||
var isOwnReserved = card.classList.contains('sig-reserved--own');
|
||||
if (reservedByOther || isOwnReserved) return;
|
||||
// If the tap is on the OK button, let the synthetic click fire normally
|
||||
if (e.target.closest('.sig-ok-btn')) return;
|
||||
focusCard(card);
|
||||
e.preventDefault(); // prevent ghost click on card body
|
||||
}, { passive: false });
|
||||
});
|
||||
|
||||
// Touch outside the grid — dismiss stage preview (unfocused state only).
|
||||
// Card touchstart doesn't stop propagation, so we guard with closest().
|
||||
overlay.addEventListener('touchstart', function (e) {
|
||||
if (_stageFrozen || !_focusedCardEl) return;
|
||||
if (e.target.closest('.sig-deck-grid')) return;
|
||||
deckGrid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) {
|
||||
c.classList.remove('sig-focused');
|
||||
});
|
||||
updateStage(null);
|
||||
}, { passive: true });
|
||||
|
||||
// Click delegation: card body → focus (shows OK); OK/NVM buttons → reserve/release
|
||||
deckGrid.addEventListener('click', function (e) {
|
||||
if (e.target.closest('.sig-ok-btn')) {
|
||||
if (_reservedCardId) return; // already holding — must NVM first
|
||||
var card = e.target.closest('.sig-card');
|
||||
if (card) doReserve(card);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('.sig-nvm-btn')) {
|
||||
doRelease();
|
||||
return;
|
||||
}
|
||||
var card = e.target.closest('.sig-card');
|
||||
if (!card) return;
|
||||
if (!isEligible()) return;
|
||||
var activeRole = getActiveRole();
|
||||
var cardId = card.dataset.cardId;
|
||||
var deckType = card.dataset.deck;
|
||||
window.showGuard(card, 'Select this significator?', function () {
|
||||
fetch(selectUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': getCsrf(),
|
||||
},
|
||||
body: 'card_id=' + encodeURIComponent(cardId) + '&deck_type=' + encodeURIComponent(deckType),
|
||||
}).then(function (response) {
|
||||
if (response.ok) {
|
||||
applySelection(cardId, activeRole, deckType);
|
||||
}
|
||||
});
|
||||
});
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('room:sig_selected', function (e) {
|
||||
if (!sigDeck) return;
|
||||
var cardId = String(e.detail.card_id);
|
||||
var role = e.detail.role;
|
||||
var deckType = e.detail.deck_type;
|
||||
// Idempotent — skip if this copy already removed (local selector already did it)
|
||||
if (!sigDeck.querySelector('.sig-card.' + deckType + '-deck[data-card-id="' + cardId + '"]')) return;
|
||||
applySelection(cardId, role, deckType);
|
||||
});
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// ── Test API ──────────────────────────────────────────────────────────
|
||||
return {
|
||||
_testInit: function () {
|
||||
_focusedCardEl = null;
|
||||
_reservedCardId = null;
|
||||
_stageFrozen = false;
|
||||
_requestInFlight = false;
|
||||
_cautionData = [];
|
||||
_cautionIdx = 0;
|
||||
Object.keys(_floatingCursors).forEach(function (k) { _floatingCursors[k].remove(); });
|
||||
_floatingCursors = {};
|
||||
Object.keys(_reservedFloats).forEach(function (k) { _reservedFloats[k].remove(); });
|
||||
_reservedFloats = {};
|
||||
_cursorPortal = null;
|
||||
init();
|
||||
},
|
||||
_setFrozen: function (v) { _stageFrozen = v; },
|
||||
_setReservedCardId: function (id) { _reservedCardId = id; },
|
||||
};
|
||||
}());
|
||||
|
||||
@@ -14,6 +14,13 @@ var Tray = (function () {
|
||||
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;
|
||||
@@ -94,12 +101,7 @@ var Tray = (function () {
|
||||
// Closed: tray hidden above viewport, handle visible at y=0.
|
||||
_maxTop = -(gearBtnTop - handleH);
|
||||
} else {
|
||||
// Portrait: slide on X axis.
|
||||
// Wrap width is pinned to viewportW (JS) so its right edge only
|
||||
// reaches the viewport boundary when left = 0 (fully open).
|
||||
// This mirrors landscape: the open edge appears only at the last moment.
|
||||
// Open: left = 0 → wrap right = viewportW exactly.
|
||||
// Closed: left = viewportW - handleW → tray fully off-screen right.
|
||||
// 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;
|
||||
@@ -251,7 +253,13 @@ var Tray = (function () {
|
||||
|
||||
firstCell.classList.add('tray-role-card');
|
||||
firstCell.dataset.role = roleCode;
|
||||
firstCell.textContent = 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 () {
|
||||
@@ -290,11 +298,48 @@ var Tray = (function () {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
@@ -306,8 +351,8 @@ var Tray = (function () {
|
||||
if (_wrap) _wrap.style.top = _maxTop + 'px';
|
||||
_computeCellSize();
|
||||
} else {
|
||||
// Clear landscape's inline top so portrait CSS applies.
|
||||
if (_wrap) _wrap.style.top = '';
|
||||
// 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();
|
||||
@@ -403,42 +448,8 @@ var Tray = (function () {
|
||||
};
|
||||
_btn.addEventListener('click', _onBtnClick);
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
// Always close on resize: bounds change invalidates current position.
|
||||
// Cancel any in-flight close animation, then force-close state.
|
||||
_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();
|
||||
// Snap to closed without transition (resize fires continuously).
|
||||
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 = ''; }
|
||||
_computeBounds();
|
||||
_applyVerticalBounds();
|
||||
_computeCellSize();
|
||||
// Snap to closed without transition.
|
||||
if (_wrap) {
|
||||
_wrap.classList.add('tray-dragging');
|
||||
_wrap.style.left = _maxLeft + 'px';
|
||||
void _wrap.offsetWidth; // flush reflow
|
||||
_wrap.classList.remove('tray-dragging');
|
||||
}
|
||||
}
|
||||
});
|
||||
window.addEventListener('resize', _reposition);
|
||||
window.addEventListener('resize:end', _reposition);
|
||||
}
|
||||
|
||||
// reset() — restores module state; used by Jasmine afterEach
|
||||
|
||||
@@ -164,3 +164,98 @@ class CursorMoveConsumerTest(TransactionTestCase):
|
||||
|
||||
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.utils import timezone
|
||||
|
||||
from django.db import IntegrityError
|
||||
|
||||
from apps.lyric.models import Token, User
|
||||
from apps.epic.models import (
|
||||
DeckVariant, GateSlot, Room, RoomInvite, TableSeat, TarotCard,
|
||||
debit_token, select_token, sig_deck_cards, sig_seat_order, active_sig_seat,
|
||||
DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
||||
debit_token, select_token, sig_deck_cards, levity_sig_cards, gravity_sig_cards,
|
||||
sig_seat_order, active_sig_seat,
|
||||
)
|
||||
|
||||
|
||||
@@ -266,16 +269,16 @@ class SigDeckCompositionTest(TestCase):
|
||||
cards = sig_deck_cards(self.room)
|
||||
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)
|
||||
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
|
||||
self.assertEqual(len(sc_ac), 16)
|
||||
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in sc_ac))
|
||||
|
||||
def test_pc_bc_contribute_court_cards_of_wands_and_pentacles(self):
|
||||
def test_pc_bc_contribute_court_cards_of_brands_and_crowns(self):
|
||||
cards = sig_deck_cards(self.room)
|
||||
pc_bc = [c for c in cards if c.suit in ("WANDS", "PENTACLES")]
|
||||
pc_bc = [c for c in cards if c.suit in ("BRANDS", "CROWNS")]
|
||||
self.assertEqual(len(pc_bc), 16)
|
||||
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in pc_bc))
|
||||
|
||||
@@ -339,7 +342,7 @@ class SigCardFieldTest(TestCase):
|
||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||
)
|
||||
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")
|
||||
room = Room.objects.create(name="Field Test", owner=owner)
|
||||
@@ -360,3 +363,170 @@ class SigCardFieldTest(TestCase):
|
||||
self.card.delete()
|
||||
self.seat.refresh_from_db()
|
||||
self.assertIsNone(self.seat.significator)
|
||||
|
||||
|
||||
# ── SigReservation model ──────────────────────────────────────────────────────
|
||||
|
||||
def _make_sig_card(deck_variant, suit, number):
|
||||
name_map = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
|
||||
card, _ = TarotCard.objects.get_or_create(
|
||||
deck_variant=deck_variant,
|
||||
slug=f"{name_map[number].lower()}-of-{suit.lower()}-em",
|
||||
defaults={
|
||||
"arcana": "MINOR", "suit": suit, "number": number,
|
||||
"name": f"{name_map[number]} of {suit.capitalize()}",
|
||||
},
|
||||
)
|
||||
return card
|
||||
|
||||
|
||||
class SigReservationModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.earthman, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||
)
|
||||
self.owner = User.objects.create(email="founder@test.io")
|
||||
self.room = Room.objects.create(name="Sig Room", owner=self.owner)
|
||||
self.card = _make_sig_card(self.earthman, "WANDS", 14)
|
||||
self.seat = TableSeat.objects.create(
|
||||
room=self.room, gamer=self.owner, slot_number=1, role="PC"
|
||||
)
|
||||
|
||||
def test_can_create_sig_reservation(self):
|
||||
res = SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
self.assertEqual(res.role, "PC")
|
||||
self.assertEqual(res.polarity, "levity")
|
||||
self.assertIsNotNone(res.reserved_at)
|
||||
|
||||
def test_one_reservation_per_gamer_per_room(self):
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
card2 = _make_sig_card(self.earthman, "CUPS", 13)
|
||||
with self.assertRaises(IntegrityError):
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=card2, role="PC", polarity="levity"
|
||||
)
|
||||
|
||||
def test_same_card_blocked_within_same_polarity(self):
|
||||
gamer2 = User.objects.create(email="nc@test.io")
|
||||
TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="NC")
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
with self.assertRaises(IntegrityError):
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=gamer2, card=self.card, role="NC", polarity="levity"
|
||||
)
|
||||
|
||||
def test_same_card_allowed_across_polarity(self):
|
||||
"""A gravity gamer may reserve the same card instance as a levity gamer
|
||||
— each polarity has its own independent pile."""
|
||||
gamer2 = User.objects.create(email="bc@test.io")
|
||||
TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="BC")
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
res2 = SigReservation.objects.create(
|
||||
room=self.room, gamer=gamer2, card=self.card, role="BC", polarity="gravity"
|
||||
)
|
||||
self.assertIsNotNone(res2.pk)
|
||||
|
||||
def test_deleting_reservation_clears_slot(self):
|
||||
res = SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
res.delete()
|
||||
self.assertFalse(SigReservation.objects.filter(room=self.room, gamer=self.owner).exists())
|
||||
|
||||
|
||||
class SigCardHelperTest(TestCase):
|
||||
"""levity_sig_cards() and gravity_sig_cards() return 18 cards each.
|
||||
Relies on the Earthman deck seeded by migrations (no manual card creation).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# Earthman deck is already seeded by migrations
|
||||
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||
self.owner = User.objects.create(email="founder@test.io")
|
||||
self.owner.equipped_deck = self.earthman
|
||||
self.owner.save()
|
||||
self.room = Room.objects.create(name="Card Test", owner=self.owner)
|
||||
|
||||
def test_levity_sig_cards_returns_18(self):
|
||||
cards = levity_sig_cards(self.room)
|
||||
self.assertEqual(len(cards), 18)
|
||||
|
||||
def test_gravity_sig_cards_returns_18(self):
|
||||
cards = gravity_sig_cards(self.room)
|
||||
self.assertEqual(len(cards), 18)
|
||||
|
||||
def test_levity_and_gravity_share_same_card_objects(self):
|
||||
"""Both piles draw from the same 18 TarotCard instances — visual distinction
|
||||
comes from CSS polarity class, not separate card model records."""
|
||||
levity = levity_sig_cards(self.room)
|
||||
gravity = gravity_sig_cards(self.room)
|
||||
self.assertEqual(
|
||||
sorted(c.pk for c in levity),
|
||||
sorted(c.pk for c in gravity),
|
||||
)
|
||||
|
||||
def test_returns_empty_when_no_equipped_deck(self):
|
||||
self.owner.equipped_deck = None
|
||||
self.owner.save()
|
||||
self.assertEqual(levity_sig_cards(self.room), [])
|
||||
self.assertEqual(gravity_sig_cards(self.room), [])
|
||||
|
||||
|
||||
class TarotCardCautionsTest(TestCase):
|
||||
"""TarotCard.cautions JSONField — field existence and Schizo seed data."""
|
||||
|
||||
def setUp(self):
|
||||
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||
|
||||
def test_cautions_field_saves_and_retrieves_list(self):
|
||||
card = TarotCard.objects.create(
|
||||
deck_variant=self.earthman,
|
||||
arcana="MINOR",
|
||||
suit="CROWNS",
|
||||
number=99,
|
||||
name="Test Card",
|
||||
slug="test-card-cautions",
|
||||
cautions=["First caution.", "Second caution."],
|
||||
)
|
||||
card.refresh_from_db()
|
||||
self.assertEqual(card.cautions, ["First caution.", "Second caution."])
|
||||
|
||||
def test_cautions_defaults_to_empty_list(self):
|
||||
card = TarotCard.objects.create(
|
||||
deck_variant=self.earthman,
|
||||
arcana="MINOR",
|
||||
suit="CROWNS",
|
||||
number=98,
|
||||
name="Default Cautions Card",
|
||||
slug="default-cautions-card",
|
||||
)
|
||||
self.assertEqual(card.cautions, [])
|
||||
|
||||
def test_schizo_has_4_cautions(self):
|
||||
schizo = TarotCard.objects.get(
|
||||
deck_variant=self.earthman, arcana="MAJOR", number=1
|
||||
)
|
||||
self.assertEqual(len(schizo.cautions), 4)
|
||||
|
||||
def test_schizo_caution_references_the_pervert(self):
|
||||
schizo = TarotCard.objects.get(
|
||||
deck_variant=self.earthman, arcana="MAJOR", number=1
|
||||
)
|
||||
self.assertIn("The Pervert", schizo.cautions[0])
|
||||
|
||||
def test_schizo_cautions_use_reverse_language(self):
|
||||
schizo = TarotCard.objects.get(
|
||||
deck_variant=self.earthman, arcana="MAJOR", number=1
|
||||
)
|
||||
for caution in schizo.cautions:
|
||||
self.assertIn("reverse", caution)
|
||||
self.assertNotIn("transform", caution)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
@@ -8,7 +8,7 @@ from django.utils import timezone
|
||||
from apps.drama.models import GameEvent
|
||||
from apps.lyric.models import Token, User
|
||||
from apps.epic.models import (
|
||||
DeckVariant, GateSlot, Room, RoomInvite, TableSeat, TarotCard,
|
||||
DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
||||
)
|
||||
|
||||
|
||||
@@ -926,7 +926,7 @@ def _full_sig_setUp(test_case, role_order=None):
|
||||
room.table_status = Room.SIG_SELECT
|
||||
room.save()
|
||||
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)
|
||||
return room, gamers, earthman, card_in_deck
|
||||
@@ -943,9 +943,9 @@ class SigSelectRenderingTest(TestCase):
|
||||
response = self.client.get(self.url)
|
||||
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)
|
||||
self.assertEqual(response.content.decode().count('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):
|
||||
response = self.client.get(self.url)
|
||||
@@ -963,6 +963,32 @@ class SigSelectRenderingTest(TestCase):
|
||||
response = self.client.get(self.url)
|
||||
self.assertNotContains(response, "id_sig_deck")
|
||||
|
||||
def test_sig_cards_render_keyword_data_attributes(self):
|
||||
response = self.client.get(self.url)
|
||||
content = response.content.decode()
|
||||
self.assertIn("data-keywords-upright=", content)
|
||||
self.assertIn("data-keywords-reversed=", content)
|
||||
|
||||
def test_sig_stat_block_structure_rendered(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "sig-stat-block")
|
||||
self.assertContains(response, "sig-flip-btn")
|
||||
self.assertContains(response, "stat-face--upright")
|
||||
self.assertContains(response, "stat-face--reversed")
|
||||
|
||||
def test_sig_cards_render_cautions_data_attribute(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "data-cautions=")
|
||||
|
||||
def test_sig_caution_tooltip_structure_rendered(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "sig-caution-tooltip")
|
||||
self.assertContains(response, "sig-caution-btn")
|
||||
self.assertContains(response, "sig-caution-effect")
|
||||
self.assertContains(response, "sig-caution-index")
|
||||
self.assertContains(response, "sig-caution-prev")
|
||||
self.assertContains(response, "sig-caution-next")
|
||||
|
||||
|
||||
class SelectSigCardViewTest(TestCase):
|
||||
"""select_sig view — records choice, enforces turn order, rejects bad input."""
|
||||
@@ -1000,8 +1026,8 @@ class SelectSigCardViewTest(TestCase):
|
||||
def test_select_sig_card_not_in_deck_returns_400(self):
|
||||
# Create a pip card (number=5) — not in the sig deck (only court 11–14 + major 0–1)
|
||||
other = TarotCard.objects.create(
|
||||
deck_variant=self.earthman, arcana="MINOR", suit="WANDS", number=5,
|
||||
name="Five of Wands Test", slug="five-of-wands-test",
|
||||
deck_variant=self.earthman, arcana="MINOR", suit="BRANDS", number=5,
|
||||
name="Five of Brands Test", slug="five-of-brands-test",
|
||||
keywords_upright=[], keywords_reversed=[],
|
||||
)
|
||||
response = self._post(card_id=other.id)
|
||||
@@ -1119,3 +1145,164 @@ class SelectRoleRecordsRoleSelectedTest(TestCase):
|
||||
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
|
||||
|
||||
@@ -16,6 +16,7 @@ urlpatterns = [
|
||||
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-sig', views.select_sig, name='select_sig'),
|
||||
path('room/<uuid:room_id>/sig-reserve', views.sig_reserve, name='sig_reserve'),
|
||||
path('room/<uuid:room_id>/gate/invite', views.invite_gamer, name='invite_gamer'),
|
||||
path('room/<uuid:room_id>/gate/status', views.gate_status, name='gate_status'),
|
||||
path('room/<uuid:room_id>/delete', views.delete_room, name='delete_room'),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from datetime import timedelta
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
@@ -9,9 +10,13 @@ from django.shortcuts import redirect, render
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.drama.models import GameEvent, record
|
||||
from django.db.models import Case, IntegerField, Value, When
|
||||
|
||||
from apps.epic.models import (
|
||||
GateSlot, Room, RoomInvite, TableSeat, TarotCard, TarotDeck,
|
||||
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
|
||||
|
||||
@@ -74,8 +79,43 @@ def _notify_sig_selected(room_id, card_id, role, deck_type='levity'):
|
||||
)
|
||||
|
||||
|
||||
_LEVITY_ROLES = {'PC', 'NC', 'SC'}
|
||||
_GRAVITY_ROLES = {'BC', 'EC', 'AC'}
|
||||
|
||||
|
||||
def _notify_sig_reserved(room_id, card_id, role, reserved):
|
||||
"""Broadcast a sig_reserved event to the matching polarity cursor group."""
|
||||
polarity = 'levity' if role in _LEVITY_ROLES else 'gravity'
|
||||
async_to_sync(get_channel_layer().group_send)(
|
||||
f'cursors_{room_id}_{polarity}',
|
||||
{'type': 'sig_reserved', 'card_id': str(card_id) if card_id else None,
|
||||
'role': role, 'reserved': reserved},
|
||||
)
|
||||
|
||||
|
||||
SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}
|
||||
|
||||
_SIG_SEAT_ORDERING = Case(
|
||||
*[When(role=r, then=Value(i)) for i, r in enumerate(SIG_SEAT_ORDER)],
|
||||
default=Value(99),
|
||||
output_field=IntegerField(),
|
||||
)
|
||||
|
||||
|
||||
def _canonical_user_seat(room, user):
|
||||
"""Return the user's seat whose role comes first in PC→NC→EC→SC→AC→BC order.
|
||||
|
||||
In normal play (one user = one seat) this is equivalent to .first().
|
||||
For Carte Blanche (one user = all seats) it returns the PC seat, ensuring
|
||||
sig-select cursor placement is seat-based, not position/slot-based.
|
||||
"""
|
||||
return room.table_seats.filter(gamer=user).order_by(_SIG_SEAT_ORDERING).first()
|
||||
|
||||
_ROLE_SCRAWL_NAMES = {
|
||||
"PC": "Player", "NC": "Narrator", "EC": "Economist",
|
||||
"SC": "Shepherd", "AC": "Alchemist", "BC": "Builder",
|
||||
}
|
||||
|
||||
|
||||
def _gate_positions(room):
|
||||
"""Return list of dicts [{slot, role_label, role_assigned}] for _table_positions.html."""
|
||||
@@ -200,10 +240,16 @@ def _role_select_context(room, user):
|
||||
if user.is_authenticated else []
|
||||
)
|
||||
active_slot = active_seat.slot_number if active_seat else None
|
||||
_my_role = assigned_seats[0].role if assigned_seats else None
|
||||
ctx = {
|
||||
"card_stack_state": card_stack_state,
|
||||
"starter_roles": starter_roles,
|
||||
"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_slots": list(
|
||||
room.table_seats.filter(gamer=user, role__isnull=True)
|
||||
@@ -215,17 +261,36 @@ def _role_select_context(room, user):
|
||||
"slots": room.gate_slots.order_by("slot_number"),
|
||||
}
|
||||
if room.table_status == Room.SIG_SELECT:
|
||||
user_seat = room.table_seats.filter(gamer=user).first() if user.is_authenticated else None
|
||||
partner_role = TableSeat.PARTNER_MAP.get(user_seat.role) if user_seat and user_seat.role else None
|
||||
partner_seat = room.table_seats.filter(role=partner_role).first() if partner_role else None
|
||||
user_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
|
||||
user_role = user_seat.role if user_seat else None
|
||||
user_polarity = None
|
||||
if user_role in _LEVITY_ROLES:
|
||||
user_polarity = 'levity'
|
||||
elif user_role in _GRAVITY_ROLES:
|
||||
user_polarity = 'gravity'
|
||||
|
||||
ctx["user_seat"] = user_seat
|
||||
ctx["partner_seat"] = partner_seat
|
||||
ctx["revealed_seats"] = room.table_seats.filter(role_revealed=True).order_by("slot_number")
|
||||
raw_sig_cards = sig_deck_cards(room)
|
||||
half = len(raw_sig_cards) // 2
|
||||
ctx["sig_cards"] = [(c, 'levity') for c in raw_sig_cards[:half]] + [(c, 'gravity') for c in raw_sig_cards[half:]]
|
||||
ctx["sig_seats"] = sig_seat_order(room)
|
||||
ctx["sig_active_seat"] = active_sig_seat(room)
|
||||
ctx["user_polarity"] = user_polarity
|
||||
ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve"
|
||||
|
||||
# Pre-load existing reservations for this polarity so JS can restore
|
||||
# grabbed state on page load/refresh. Keyed by str(card_id) → role.
|
||||
if user_polarity:
|
||||
polarity_const = SigReservation.LEVITY if user_polarity == 'levity' else SigReservation.GRAVITY
|
||||
reservations = {
|
||||
str(res.card_id): res.role
|
||||
for res in room.sig_reservations.filter(polarity=polarity_const)
|
||||
}
|
||||
else:
|
||||
reservations = {}
|
||||
ctx["sig_reservations_json"] = json.dumps(reservations)
|
||||
|
||||
if user_polarity == 'levity':
|
||||
ctx["sig_cards"] = levity_sig_cards(room)
|
||||
elif user_polarity == 'gravity':
|
||||
ctx["sig_cards"] = gravity_sig_cards(room)
|
||||
else:
|
||||
ctx["sig_cards"] = []
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -526,6 +591,62 @@ def gate_status(request, room_id):
|
||||
return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)
|
||||
|
||||
|
||||
@login_required
|
||||
def sig_reserve(request, room_id):
|
||||
"""Provisional card hold (OK / NVM) during SIG_SELECT.
|
||||
POST body: card_id=<uuid>, action=reserve|release
|
||||
"""
|
||||
if request.method != "POST":
|
||||
return HttpResponse(status=405)
|
||||
room = Room.objects.get(id=room_id)
|
||||
if room.table_status != Room.SIG_SELECT:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
user_seat = _canonical_user_seat(room, request.user)
|
||||
if not user_seat or not user_seat.role:
|
||||
return HttpResponse(status=403)
|
||||
|
||||
action = request.POST.get("action", "reserve")
|
||||
|
||||
if action == "release":
|
||||
existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
|
||||
released_card_id = existing.card_id if existing else None
|
||||
SigReservation.objects.filter(room=room, gamer=request.user).delete()
|
||||
_notify_sig_reserved(room_id, released_card_id, user_seat.role, reserved=False)
|
||||
return HttpResponse(status=200)
|
||||
|
||||
# Reserve action
|
||||
card_id = request.POST.get("card_id")
|
||||
try:
|
||||
card = TarotCard.objects.get(pk=card_id)
|
||||
except TarotCard.DoesNotExist:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
polarity = SigReservation.LEVITY if user_seat.role in _LEVITY_ROLES else SigReservation.GRAVITY
|
||||
|
||||
# Block if another gamer in the same polarity already holds this card
|
||||
if SigReservation.objects.filter(
|
||||
room=room, card=card, polarity=polarity
|
||||
).exclude(gamer=request.user).exists():
|
||||
return HttpResponse(status=409)
|
||||
|
||||
# Block if this gamer already holds a *different* card — must NVM first
|
||||
existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
|
||||
if existing and existing.card != card:
|
||||
return HttpResponse(status=409)
|
||||
|
||||
# Idempotent: already holding the same card
|
||||
if existing:
|
||||
return HttpResponse(status=200)
|
||||
|
||||
SigReservation.objects.create(
|
||||
room=room, gamer=request.user, card=card,
|
||||
seat=user_seat, role=user_seat.role, polarity=polarity,
|
||||
)
|
||||
_notify_sig_reserved(room_id, card.pk, user_seat.role, reserved=True)
|
||||
return HttpResponse(status=200)
|
||||
|
||||
|
||||
@login_required
|
||||
def select_sig(request, room_id):
|
||||
if request.method != "POST":
|
||||
|
||||
@@ -139,8 +139,18 @@ function initGameKitTooltips() {
|
||||
const rawLeft = tokenRect.left + tokenRect.width / 2;
|
||||
const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8));
|
||||
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.transform = `translate(-50%, calc(-100% - 0.5rem - ${miniHeight}px))`;
|
||||
}
|
||||
|
||||
if (isEquippable) {
|
||||
const mainRect = portal.getBoundingClientRect();
|
||||
|
||||
@@ -150,7 +150,7 @@ def tarot_fan(request, deck_id):
|
||||
deck = get_object_or_404(DeckVariant, pk=deck_id)
|
||||
if not request.user.unlocked_decks.filter(pk=deck_id).exists():
|
||||
return HttpResponse(status=403)
|
||||
_suit_order = {"WANDS": 0, "CUPS": 1, "SWORDS": 2, "PENTACLES": 3, "COINS": 4}
|
||||
_suit_order = {"WANDS": 0, "CUPS": 1, "SWORDS": 2, "PENTACLES": 3, "CROWNS": 3, "COINS": 4}
|
||||
cards = sorted(
|
||||
TarotCard.objects.filter(deck_variant=deck),
|
||||
key=lambda c: (0 if c.arcana == "MAJOR" else 1, _suit_order.get(c.suit or "", 9), c.number),
|
||||
|
||||
@@ -5,6 +5,7 @@ from . import views as lyric_views
|
||||
urlpatterns = [
|
||||
path('send_login_email', lyric_views.send_login_email, name='send_login_email'),
|
||||
path('login', lyric_views.login, name='login'),
|
||||
path('logout', auth_views.LogoutView.as_view(next_page='/'), name='logout')
|
||||
path('logout', auth_views.LogoutView.as_view(next_page='/'), name='logout'),
|
||||
path('dev-login/<str:session_key>/', lyric_views.dev_login, name='dev_login'),
|
||||
]
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import auth, messages
|
||||
from django.http import Http404
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -27,3 +29,13 @@ def login(request):
|
||||
else:
|
||||
messages.error(request, "Invalid login link!—please request another")
|
||||
return redirect("/")
|
||||
|
||||
|
||||
def dev_login(request, session_key):
|
||||
"""DEBUG-only: set session cookie and redirect. Used by setup_sig_session command."""
|
||||
if not settings.DEBUG:
|
||||
raise Http404
|
||||
next_url = request.GET.get("next", "/")
|
||||
response = redirect(next_url)
|
||||
response.set_cookie(settings.SESSION_COOKIE_NAME, session_key, httponly=True)
|
||||
return response
|
||||
|
||||
@@ -2,3 +2,29 @@ def user_palette(request):
|
||||
if request.user.is_authenticated:
|
||||
return {"user_palette": request.user.palette}
|
||||
return {"user_palette": "palette-default"}
|
||||
|
||||
|
||||
def navbar_context(request):
|
||||
if not request.user.is_authenticated:
|
||||
return {}
|
||||
from django.db.models import Max, Q
|
||||
from django.urls import reverse
|
||||
from apps.epic.models import Room
|
||||
|
||||
recent_room = (
|
||||
Room.objects.filter(
|
||||
Q(owner=request.user) | Q(gate_slots__gamer=request.user)
|
||||
)
|
||||
.annotate(last_event=Max("events__timestamp"))
|
||||
.filter(last_event__isnull=False)
|
||||
.order_by("-last_event")
|
||||
.distinct()
|
||||
.first()
|
||||
)
|
||||
if recent_room is None:
|
||||
return {}
|
||||
if recent_room.table_status:
|
||||
url = reverse("epic:room", args=[recent_room.id])
|
||||
else:
|
||||
url = reverse("epic:gatekeeper", args=[recent_room.id])
|
||||
return {"navbar_recent_room_url": url}
|
||||
@@ -57,13 +57,13 @@ INSTALLED_APPS = [
|
||||
# Board apps
|
||||
'apps.dashboard',
|
||||
'apps.gameboard',
|
||||
'apps.billboard',
|
||||
# Gamer apps
|
||||
'apps.lyric',
|
||||
'apps.epic',
|
||||
'apps.drama',
|
||||
'apps.billboard',
|
||||
'apps.ap',
|
||||
# Custom apps
|
||||
'apps.ap',
|
||||
'apps.api',
|
||||
'apps.applets',
|
||||
'functional_tests',
|
||||
@@ -102,6 +102,7 @@ TEMPLATES = [
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'core.context_processors.user_palette',
|
||||
'core.context_processors.navbar_context',
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
109
src/core/tests/unit/test_context_processors.py
Normal file
109
src/core/tests/unit/test_context_processors.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from django.test import TestCase, RequestFactory
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.drama.models import GameEvent, record
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
from core.context_processors import navbar_context
|
||||
|
||||
|
||||
class NavbarContextProcessorTest(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def _anon_request(self):
|
||||
req = self.factory.get("/")
|
||||
req.user = MagicMock(is_authenticated=False)
|
||||
return req
|
||||
|
||||
def _auth_request(self, user):
|
||||
req = self.factory.get("/")
|
||||
req.user = user
|
||||
return req
|
||||
|
||||
def _room_with_event(self, owner, name="Test Room"):
|
||||
room = Room.objects.create(name=name, owner=owner)
|
||||
record(
|
||||
room, GameEvent.SLOT_FILLED, actor=owner,
|
||||
slot_number=1, token_type="coin",
|
||||
token_display="Coin-on-a-String", renewal_days=7,
|
||||
)
|
||||
return room
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Anonymous user #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_returns_empty_for_anonymous_user(self):
|
||||
ctx = navbar_context(self._anon_request())
|
||||
self.assertEqual(ctx, {})
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Authenticated user — no rooms #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_returns_empty_when_no_rooms_with_events(self):
|
||||
user = User.objects.create(email="disco@test.io")
|
||||
# Room exists but has no events
|
||||
Room.objects.create(name="Empty Room", owner=user)
|
||||
|
||||
ctx = navbar_context(self._auth_request(user))
|
||||
self.assertEqual(ctx, {})
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Room in gate phase (no table_status) → gatekeeper URL #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_returns_gatekeeper_url_for_gate_phase_room(self):
|
||||
user = User.objects.create(email="disco@test.io")
|
||||
room = self._room_with_event(user)
|
||||
|
||||
ctx = navbar_context(self._auth_request(user))
|
||||
self.assertIn("navbar_recent_room_url", ctx)
|
||||
self.assertIn(str(room.id), ctx["navbar_recent_room_url"])
|
||||
self.assertIn("gate", ctx["navbar_recent_room_url"])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Room in role-select (table_status set) → room view URL #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_returns_room_url_for_table_status_room(self):
|
||||
user = User.objects.create(email="disco@test.io")
|
||||
room = self._room_with_event(user)
|
||||
room.table_status = Room.ROLE_SELECT
|
||||
room.save()
|
||||
|
||||
ctx = navbar_context(self._auth_request(user))
|
||||
self.assertIn("navbar_recent_room_url", ctx)
|
||||
self.assertIn(str(room.id), ctx["navbar_recent_room_url"])
|
||||
self.assertNotIn("gate", ctx["navbar_recent_room_url"])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Most recently updated room is chosen #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_returns_most_recently_updated_room(self):
|
||||
user = User.objects.create(email="disco@test.io")
|
||||
older_room = self._room_with_event(user, name="Older Room")
|
||||
newer_room = self._room_with_event(user, name="Newer Room")
|
||||
|
||||
ctx = navbar_context(self._auth_request(user))
|
||||
self.assertIn(str(newer_room.id), ctx["navbar_recent_room_url"])
|
||||
self.assertNotIn(str(older_room.id), ctx["navbar_recent_room_url"])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# User sees own rooms but not others' rooms they never joined #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_ignores_rooms_user_has_no_connection_to(self):
|
||||
owner = User.objects.create(email="owner@test.io")
|
||||
other = User.objects.create(email="other@test.io")
|
||||
# Create a room belonging only to `owner`
|
||||
self._room_with_event(owner)
|
||||
|
||||
ctx = navbar_context(self._auth_request(other))
|
||||
self.assertEqual(ctx, {})
|
||||
128
src/functional_tests/management/commands/setup_sig_session.py
Normal file
128
src/functional_tests/management/commands/setup_sig_session.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Management command for manual multi-user sig-select testing.
|
||||
|
||||
Creates (or reuses) a room with all 6 gate slots filled, roles assigned,
|
||||
and table_status=SIG_SELECT. Prints one pre-auth URL per gamer so you can
|
||||
paste them into 6 Firefox Multi-Account Container tabs.
|
||||
|
||||
Usage:
|
||||
python src/manage.py setup_sig_session
|
||||
python src/manage.py setup_sig_session --base-url http://localhost:8000
|
||||
python src/manage.py setup_sig_session --room <uuid> # reuse existing room
|
||||
"""
|
||||
|
||||
from django.contrib.auth import BACKEND_SESSION_KEY, HASH_SESSION_KEY, SESSION_KEY
|
||||
from django.contrib.sessions.backends.db import SessionStore
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.epic.models import DeckVariant, GateSlot, Room, TableSeat, TarotCard
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
GAMERS = [
|
||||
("founder@test.io", "discoman"),
|
||||
("amigo@test.io", "amigo"),
|
||||
("bud@test.io", "bud"),
|
||||
("pal@test.io", "pal"),
|
||||
("dude@test.io", "dude"),
|
||||
("bro@test.io", "bro"),
|
||||
]
|
||||
|
||||
ROLES = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||||
|
||||
|
||||
def _ensure_earthman():
|
||||
"""Return (or create) the Earthman DeckVariant with enough sig-deck cards seeded."""
|
||||
earthman, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||
)
|
||||
_NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
|
||||
for suit in ("WANDS", "PENTACLES", "SWORDS", "CUPS"):
|
||||
for number in (11, 12, 13, 14):
|
||||
TarotCard.objects.get_or_create(
|
||||
deck_variant=earthman,
|
||||
slug=f"{_NAME[number].lower()}-of-{suit.lower()}-em",
|
||||
defaults={
|
||||
"arcana": "MINOR",
|
||||
"suit": suit,
|
||||
"number": number,
|
||||
"name": f"{_NAME[number]} of {suit.capitalize()}",
|
||||
},
|
||||
)
|
||||
return earthman
|
||||
|
||||
|
||||
def _make_session(user):
|
||||
session = SessionStore()
|
||||
session[SESSION_KEY] = str(user.pk)
|
||||
session[BACKEND_SESSION_KEY] = "apps.lyric.authentication.PasswordlessAuthenticationBackend"
|
||||
session[HASH_SESSION_KEY] = user.get_session_auth_hash()
|
||||
session.save()
|
||||
return session.session_key
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Set up a SIG_SELECT room and print pre-auth URLs for all six gamers"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--base-url", default="http://localhost:8000")
|
||||
parser.add_argument("--room", default=None, help="UUID of an existing room to reuse")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
base_url = options["base_url"].rstrip("/")
|
||||
earthman = _ensure_earthman()
|
||||
|
||||
# ── Users ────────────────────────────────────────────────────────────
|
||||
users = []
|
||||
for email, _ in GAMERS:
|
||||
user, _ = User.objects.get_or_create(email=email)
|
||||
user.is_staff = True
|
||||
user.is_superuser = True
|
||||
if not user.equipped_deck:
|
||||
user.equipped_deck = earthman
|
||||
user.save()
|
||||
users.append(user)
|
||||
|
||||
# ── Room ─────────────────────────────────────────────────────────────
|
||||
if options["room"]:
|
||||
room = Room.objects.get(pk=options["room"])
|
||||
else:
|
||||
room = Room.objects.create(
|
||||
name="Sig Select Test Room",
|
||||
owner=users[0],
|
||||
visibility=Room.PUBLIC,
|
||||
)
|
||||
|
||||
# ── Gate slots ───────────────────────────────────────────────────────
|
||||
for i, user in enumerate(users, start=1):
|
||||
slot = room.gate_slots.get(slot_number=i)
|
||||
slot.gamer = user
|
||||
slot.status = GateSlot.FILLED
|
||||
slot.save()
|
||||
|
||||
room.gate_status = Room.OPEN
|
||||
room.save()
|
||||
|
||||
# ── Table seats + roles ──────────────────────────────────────────────
|
||||
for i, (user, role) in enumerate(zip(users, ROLES), start=1):
|
||||
TableSeat.objects.update_or_create(
|
||||
room=room, slot_number=i,
|
||||
defaults={"gamer": user, "role": role, "role_revealed": True},
|
||||
)
|
||||
|
||||
room.table_status = Room.SIG_SELECT
|
||||
room.save()
|
||||
|
||||
# ── Print URLs ───────────────────────────────────────────────────────
|
||||
room_path = f"/gameboard/room/{room.pk}/"
|
||||
self.stdout.write(f"\nRoom: {base_url}{room_path}\n")
|
||||
self.stdout.write(f"{'Container':<12} {'Email':<22} {'Role':<6} URL")
|
||||
self.stdout.write("─" * 100)
|
||||
|
||||
for (email, container), user, role in zip(GAMERS, users, ROLES):
|
||||
session_key = _make_session(user)
|
||||
url = f"{base_url}/lyric/dev-login/{session_key}/?next={room_path}"
|
||||
self.stdout.write(f"{container:<12} {email:<22} {role:<6} {url}")
|
||||
|
||||
self.stdout.write("")
|
||||
@@ -442,28 +442,7 @@ class GameKitPageTest(FunctionalTest):
|
||||
self.assertGreater(len(visible), 1)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 11 — next button advances the active card #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@unittest.skip("fan-nav button obscured by dialog at 1366×900 — fix with tray/room.html styling pass")
|
||||
def test_fan_next_button_advances_card(self):
|
||||
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card")
|
||||
).click()
|
||||
first_index = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active")
|
||||
).get_attribute("data-index")
|
||||
self.browser.find_element(By.ID, "id_fan_next").click()
|
||||
self.wait_for(
|
||||
lambda: self.assertNotEqual(
|
||||
self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active").get_attribute("data-index"),
|
||||
first_index,
|
||||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 12 — clicking outside the modal closes it #
|
||||
# Test 11 — clicking outside the modal closes it #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_pressing_escape_closes_fan_modal(self):
|
||||
@@ -477,37 +456,3 @@ class GameKitPageTest(FunctionalTest):
|
||||
dialog.send_keys(Keys.ESCAPE)
|
||||
self.wait_for(lambda: self.assertFalse(dialog.is_displayed()))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test 13 — reopening the modal remembers scroll position #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@unittest.skip("fan-nav button obscured by dialog at 1366×900 — fix with tray/room.html styling pass")
|
||||
def test_fan_remembers_position_on_reopen(self):
|
||||
self.browser.get(self.live_server_url + "/gameboard/game-kit/")
|
||||
deck_card = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_gk_decks .gk-deck-card")
|
||||
)
|
||||
deck_card.click()
|
||||
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active"))
|
||||
# Advance 3 cards
|
||||
for _ in range(3):
|
||||
self.browser.find_element(By.ID, "id_fan_next").click()
|
||||
saved_index = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active").get_attribute("data-index")
|
||||
)
|
||||
# Close via ESC
|
||||
from selenium.webdriver.common.keys import Keys
|
||||
self.browser.find_element(By.ID, "id_tarot_fan_dialog").send_keys(Keys.ESCAPE)
|
||||
self.wait_for(
|
||||
lambda: self.assertFalse(
|
||||
self.browser.find_element(By.ID, "id_tarot_fan_dialog").is_displayed()
|
||||
)
|
||||
)
|
||||
# Reopen and verify position restored
|
||||
deck_card.click()
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
self.browser.find_element(By.CSS_SELECTOR, ".fan-card--active").get_attribute("data-index"),
|
||||
saved_index,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -119,6 +119,9 @@ class DashboardMaintenanceTest(FunctionalTest):
|
||||
class AppletMenuDismissTest(FunctionalTest):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Portrait viewport: sidebars don't activate, h2 sits safely above
|
||||
# #id_dash_content and can't be obscured by it regardless of font metrics.
|
||||
self.browser.set_window_size(800, 1200)
|
||||
Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
|
||||
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
|
||||
self.create_pre_authenticated_session("discoman@example.com")
|
||||
|
||||
@@ -248,7 +248,7 @@ class GatekeeperTest(FunctionalTest):
|
||||
|
||||
self.browser.find_element(By.CSS_SELECTOR, ".gear-btn").click()
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".btn-abandon")
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_room_menu .btn-abandon")
|
||||
).click()
|
||||
self.confirm_guard()
|
||||
|
||||
|
||||
182
src/functional_tests/test_navbar.py
Normal file
182
src/functional_tests/test_navbar.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from django.urls import reverse
|
||||
from selenium.webdriver.common.by import By
|
||||
|
||||
from apps.drama.models import GameEvent, record
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
from .base import FunctionalTest
|
||||
|
||||
|
||||
def _guard_rect(browser):
|
||||
"""Return the guard portal's bounding rect (reflects CSS transform)."""
|
||||
return browser.execute_script(
|
||||
"return document.getElementById('id_guard_portal').getBoundingClientRect().toJSON()"
|
||||
)
|
||||
|
||||
|
||||
def _elem_rect(browser, element):
|
||||
"""Return an element's bounding rect."""
|
||||
return browser.execute_script(
|
||||
"return arguments[0].getBoundingClientRect().toJSON()", element
|
||||
)
|
||||
|
||||
|
||||
class NavbarByeTest(FunctionalTest):
|
||||
"""
|
||||
The BYE btn-abandon replaces LOG OUT in the identity group.
|
||||
It should confirm before logging out and its tooltip must appear below
|
||||
the button (not above, which would be off-screen in the navbar).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.create_pre_authenticated_session("disco@test.io")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# T1 — BYE btn present; "Log Out" text gone #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_bye_btn_replaces_log_out(self):
|
||||
self.browser.get(self.live_server_url)
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_logout"))
|
||||
|
||||
logout_btn = self.browser.find_element(By.ID, "id_logout")
|
||||
self.assertEqual(logout_btn.text, "BYE")
|
||||
self.assertIn("btn-abandon", logout_btn.get_attribute("class"))
|
||||
self.assertNotIn("btn-primary", logout_btn.get_attribute("class"))
|
||||
|
||||
# Old "Log Out" text nowhere in navbar
|
||||
navbar = self.browser.find_element(By.CSS_SELECTOR, ".navbar")
|
||||
self.assertNotIn("Log Out", navbar.text)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# T2 — BYE tooltip appears below btn #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_bye_tooltip_appears_below_btn(self):
|
||||
self.browser.get(self.live_server_url)
|
||||
btn = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_logout")
|
||||
)
|
||||
btn_rect = _elem_rect(self.browser, btn)
|
||||
|
||||
# Click BYE — guard should become active
|
||||
self.browser.execute_script("arguments[0].click()", btn)
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_guard_portal.active"
|
||||
)
|
||||
)
|
||||
|
||||
portal_rect = _guard_rect(self.browser)
|
||||
self.assertGreaterEqual(
|
||||
portal_rect["top"],
|
||||
btn_rect["bottom"] - 2, # 2 px tolerance for sub-pixel rounding
|
||||
"Guard portal should appear below the BYE btn, not above it",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# T3 — BYE btn logs out on confirm #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_bye_btn_logs_out_on_confirm(self):
|
||||
self.browser.get(self.live_server_url)
|
||||
btn = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_logout")
|
||||
)
|
||||
self.browser.execute_script("arguments[0].click()", btn)
|
||||
self.confirm_guard()
|
||||
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "input[name=email]")
|
||||
)
|
||||
navbar = self.browser.find_element(By.CSS_SELECTOR, ".navbar")
|
||||
self.assertNotIn("disco@test.io", navbar.text)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# T4 — No CONT GAME btn when user has no rooms with events #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_cont_game_btn_absent_without_recent_room(self):
|
||||
self.browser.get(self.live_server_url)
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_logout")
|
||||
)
|
||||
cont_game_btns = self.browser.find_elements(By.ID, "id_cont_game")
|
||||
self.assertEqual(
|
||||
len(cont_game_btns), 0,
|
||||
"CONT GAME btn should not appear when user has no rooms with events",
|
||||
)
|
||||
|
||||
|
||||
class NavbarContGameTest(FunctionalTest):
|
||||
"""
|
||||
When the authenticated user has at least one room with a game event the
|
||||
CONT GAME btn-primary appears in the navbar and navigates to that
|
||||
room on confirmation. Its tooltip must also appear below the button.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.create_pre_authenticated_session("disco@test.io")
|
||||
self.user = User.objects.get(email="disco@test.io")
|
||||
self.room = Room.objects.create(name="Arena of Peril", owner=self.user)
|
||||
record(
|
||||
self.room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||
slot_number=1, token_type="coin",
|
||||
token_display="Coin-on-a-String", renewal_days=7,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# T5 — CONT GAME btn present when recent room exists #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_cont_game_btn_present(self):
|
||||
self.browser.get(self.live_server_url)
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_cont_game")
|
||||
)
|
||||
btn = self.browser.find_element(By.ID, "id_cont_game")
|
||||
self.assertIn("btn-primary", btn.get_attribute("class"))
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# T6 — CONT GAME tooltip appears below btn #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_cont_game_tooltip_appears_below_btn(self):
|
||||
self.browser.get(self.live_server_url)
|
||||
btn = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_cont_game")
|
||||
)
|
||||
btn_rect = _elem_rect(self.browser, btn)
|
||||
|
||||
self.browser.execute_script("arguments[0].click()", btn)
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_guard_portal.active"
|
||||
)
|
||||
)
|
||||
|
||||
portal_rect = _guard_rect(self.browser)
|
||||
self.assertGreaterEqual(
|
||||
portal_rect["top"],
|
||||
btn_rect["bottom"] - 2,
|
||||
"Guard portal should appear below the CONT GAME btn, not above it",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# T7 — CONT GAME navigates to the room on confirm #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_cont_game_navigates_to_room_on_confirm(self):
|
||||
self.browser.get(self.live_server_url)
|
||||
btn = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_cont_game")
|
||||
)
|
||||
self.browser.execute_script("arguments[0].click()", btn)
|
||||
self.confirm_guard()
|
||||
|
||||
self.wait_for(
|
||||
lambda: self.assertIn(str(self.room.id), self.browser.current_url)
|
||||
)
|
||||
@@ -1,5 +1,4 @@
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from django.conf import settings as django_settings
|
||||
from django.test import tag
|
||||
@@ -17,18 +16,9 @@ from .test_room_role_select import _fill_room_via_orm
|
||||
|
||||
# ── Significator Selection ────────────────────────────────────────────────────
|
||||
#
|
||||
# After all 6 roles are revealed the room enters SIG_SELECT. A 36-card
|
||||
# Significator deck appears at the table centre; gamers pick in seat order
|
||||
# (PC → NC → EC → SC → AC → BC). Selected cards are removed from the shared
|
||||
# pile in real time via WebSocket, exactly as role selection works.
|
||||
#
|
||||
# Deck composition (18 unique cards × 2 — one from levity, one from gravity):
|
||||
# SC / AC (Shepherd / Alchemist) → M/J/Q/K of Swords & Cups (16 cards)
|
||||
# PC / BC (Player / Builder) → M/J/Q/K of Wands & Pentacles (16 cards)
|
||||
# NC / EC (Narrator / Economist) → The Schiz (0) + Chancellor (1) ( 4 cards)
|
||||
#
|
||||
# Levity pile: SC, PC, NC contributions. Gravity pile: AC, BC, EC contributions.
|
||||
# Cards retain the contributor's deck card-back — up to 6 distinct backs active.
|
||||
# After all 6 roles are revealed the room enters SIG_SELECT. Two parallel
|
||||
# 18-card overlays appear (levity: PC/NC/SC; gravity: BC/EC/AC). Each polarity
|
||||
# group picks simultaneously — no sequential turn order.
|
||||
#
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -44,14 +34,15 @@ def _assign_all_roles(room, role_order=None):
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||
)
|
||||
# Seed the 18 sig deck cards (migration data is flushed in TransactionTestCase FTs)
|
||||
# Seed the 18 sig deck cards (migration data is flushed in TransactionTestCase FTs).
|
||||
# _sig_unique_cards() filters arcana=MIDDLE, suits BRANDS/CROWNS/BLADES/GRAILS (Earthman).
|
||||
_NAME = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
|
||||
for suit in ("WANDS", "PENTACLES", "SWORDS", "CUPS"):
|
||||
for suit in ("BRANDS", "CROWNS", "BLADES", "GRAILS"):
|
||||
for number in (11, 12, 13, 14):
|
||||
TarotCard.objects.get_or_create(
|
||||
deck_variant=earthman,
|
||||
slug=f"{_NAME[number].lower()}-of-{suit.lower()}-em",
|
||||
defaults={"arcana": "MINOR", "suit": suit, "number": number,
|
||||
defaults={"arcana": "MIDDLE", "suit": suit, "number": number,
|
||||
"name": f"{_NAME[number]} of {suit.capitalize()}"},
|
||||
)
|
||||
for number, name, slug in [
|
||||
@@ -93,34 +84,7 @@ class SigSelectTest(FunctionalTest):
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test S1 — Significator deck of 36 cards appears at table centre #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_sig_deck_appears_with_36_cards_after_all_roles_revealed(self):
|
||||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||
room = Room.objects.create(name="Sig Deck Test", owner=founder)
|
||||
_fill_room_via_orm(room, [
|
||||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||
])
|
||||
_assign_all_roles(room)
|
||||
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||||
self.browser.get(room_url)
|
||||
|
||||
# Significator deck is visible at the table centre
|
||||
sig_deck = self.wait_for(
|
||||
lambda: self.browser.find_element(By.ID, "id_sig_deck")
|
||||
)
|
||||
self.assertTrue(sig_deck.is_displayed())
|
||||
|
||||
# It contains exactly 36 cards
|
||||
cards = self.browser.find_elements(By.CSS_SELECTOR, "#id_sig_deck .sig-card")
|
||||
self.assertEqual(len(cards), 36)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test S2 — Seats reorder to canonical role sequence at SIG_SELECT #
|
||||
# Test S1 — Seats reorder to canonical role sequence at SIG_SELECT #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_seats_display_in_pc_nc_ec_sc_ac_bc_order_after_reveal(self):
|
||||
@@ -146,88 +110,6 @@ class SigSelectTest(FunctionalTest):
|
||||
roles_in_order = [s.get_attribute("data-role") for s in seats]
|
||||
self.assertEqual(roles_in_order, SIG_SEAT_ORDER)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test S3 — First seat (PC) can select a significator; deck shrinks #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_first_seat_pc_can_select_significator_and_deck_shrinks(self):
|
||||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||
room = Room.objects.create(name="PC Select Test", owner=founder)
|
||||
# Founder is assigned PC (slot 1 → first in canonical order → active)
|
||||
_fill_room_via_orm(room, [
|
||||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||
])
|
||||
_assign_all_roles(room, role_order=["PC", "NC", "EC", "SC", "AC", "BC"])
|
||||
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||||
self.browser.get(room_url)
|
||||
|
||||
# 36-card sig deck is present and the founder's seat is active
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_sig_deck .sig-card")
|
||||
)
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat.active[data-role='PC']"
|
||||
)
|
||||
)
|
||||
|
||||
# Click the first card in the significator deck to select it
|
||||
first_card = self.browser.find_element(
|
||||
By.CSS_SELECTOR, "#id_sig_deck .sig-card"
|
||||
)
|
||||
first_card.click()
|
||||
self.confirm_guard()
|
||||
|
||||
# Deck now has 35 cards — one pile copy of the selected card removed
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_sig_deck .sig-card")),
|
||||
35,
|
||||
)
|
||||
)
|
||||
|
||||
# TODO: sig card should appear in the tray (tray.placeCard for sig phase)
|
||||
# once sig-select.js is updated to call Tray.placeCard instead of
|
||||
# appending to the removed #id_inv_sig_card inventory element.
|
||||
|
||||
# Active seat advances to NC
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat.active[data-role='NC']"
|
||||
)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test S4 — Ineligible seat cannot interact with sig deck #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_non_active_seat_cannot_select_significator(self):
|
||||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||
room = Room.objects.create(name="Ineligible Sig Test", owner=founder)
|
||||
# Founder is NC (second in canonical order) — not first
|
||||
_fill_room_via_orm(room, [
|
||||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||
])
|
||||
_assign_all_roles(room, role_order=["NC", "PC", "EC", "SC", "AC", "BC"])
|
||||
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||||
self.browser.get(room_url)
|
||||
|
||||
self.wait_for(lambda: self.browser.find_element(By.ID, "id_sig_deck"))
|
||||
|
||||
# Click a sig card — it must not trigger a selection (deck stays at 36)
|
||||
self.browser.find_element(By.CSS_SELECTOR, "#id_sig_deck .sig-card").click()
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_sig_deck .sig-card")),
|
||||
36,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@tag("channels")
|
||||
@@ -257,61 +139,234 @@ class SigSelectChannelsTest(ChannelsFunctionalTest):
|
||||
))
|
||||
return b
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Test S5 — Selected sig card disappears for watching gamer (WS) #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@unittest.skip("sig deck card count wrong in channels context (40 != 36) — grand overhaul pending")
|
||||
def test_selected_sig_card_removed_from_deck_for_other_gamers(self):
|
||||
founder, _ = User.objects.get_or_create(email="founder@test.io")
|
||||
User.objects.get_or_create(email="watcher@test.io")
|
||||
room = Room.objects.create(name="Sig WS Test", owner=founder)
|
||||
_fill_room_via_orm(room, [
|
||||
"founder@test.io", "watcher@test.io", "bud@test.io",
|
||||
def _setup_sig_select_room(self):
|
||||
"""Create a full SIG_SELECT room; return (room, [user_pc, user_nc, ...])."""
|
||||
emails = [
|
||||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||
])
|
||||
# Founder is PC (active first); watcher is NC (second)
|
||||
_assign_all_roles(room, role_order=["PC", "NC", "EC", "SC", "AC", "BC"])
|
||||
room_url = f"{self.live_server_url}/gameboard/room/{room.id}/gate/"
|
||||
]
|
||||
founder, _ = User.objects.get_or_create(email=emails[0])
|
||||
room = Room.objects.create(name="Cursor Colour Test", owner=founder)
|
||||
gamers = _fill_room_via_orm(room, emails)
|
||||
_assign_all_roles(room)
|
||||
return room, gamers
|
||||
|
||||
# Watcher loads room, sees 36 cards
|
||||
self.create_pre_authenticated_session("watcher@test.io")
|
||||
# ── SC1: NC hover → PC sees mid cursor active, coloured --priYl ────────── #
|
||||
|
||||
@tag('channels')
|
||||
def test_nc_hover_activates_mid_cursor_in_pc_browser(self):
|
||||
"""
|
||||
When NC (levity mid) hovers a card, PC (levity left) must see the
|
||||
--mid cursor become active, coloured --priYl (rgb 255 207 52).
|
||||
Verifies: WS broadcast pipeline + JS applyHover + CSS role colouring.
|
||||
"""
|
||||
room, gamers = self._setup_sig_select_room()
|
||||
room_url = self.live_server_url + f"/gameboard/room/{room.pk}/"
|
||||
|
||||
# ── Browser 1: PC (founder) ───────────────────────────────────────────
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(room_url)
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
len(self.browser.find_elements(By.CSS_SELECTOR, "#id_sig_deck .sig-card")),
|
||||
36,
|
||||
)
|
||||
)
|
||||
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||
|
||||
# Founder picks a significator in second browser
|
||||
self.browser2 = self._make_browser2("founder@test.io")
|
||||
# ── Browser 2: NC (amigo) ─────────────────────────────────────────────
|
||||
browser2 = self._make_browser2("amigo@test.io")
|
||||
try:
|
||||
self.browser2.get(room_url)
|
||||
self.wait_for(lambda: self.browser2.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat.active[data-role='PC']"
|
||||
))
|
||||
self.browser2.find_element(
|
||||
By.CSS_SELECTOR, "#id_sig_deck .sig-card"
|
||||
).click()
|
||||
self.confirm_guard(browser=self.browser2)
|
||||
browser2.get(room_url)
|
||||
self.wait_for(lambda: browser2.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||
|
||||
# Watcher's deck shrinks to 35 without a page reload
|
||||
# Grab the first card ID visible in browser2's deck
|
||||
first_card = browser2.find_element(By.CSS_SELECTOR, ".sig-card")
|
||||
card_id = first_card.get_attribute("data-card-id")
|
||||
|
||||
# Hover over it — triggers sendHover() → WS broadcast
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
ActionChains(browser2).move_to_element(first_card).perform()
|
||||
|
||||
# ── Browser 1 should see --mid cursor go active (anchor carries class) ─
|
||||
mid_cursor_sel = f'.sig-card[data-card-id="{card_id}"] .sig-cursor--mid'
|
||||
self.wait_for(
|
||||
lambda: self.assertEqual(
|
||||
len(self.browser.find_elements(
|
||||
By.CSS_SELECTOR, "#id_sig_deck .sig-card"
|
||||
)),
|
||||
35,
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, mid_cursor_sel + ".active"
|
||||
)
|
||||
)
|
||||
|
||||
# CSS colour check: portal float has data-role="NC" → --priYl = 255, 207, 52
|
||||
portal_sel = '.sig-cursor-float[data-role="NC"]'
|
||||
portal_cursor = self.browser.find_element(By.CSS_SELECTOR, portal_sel)
|
||||
color = self.browser.execute_script(
|
||||
"return window.getComputedStyle(arguments[0]).color",
|
||||
portal_cursor,
|
||||
)
|
||||
self.assertEqual(color, "rgb(255, 207, 52)", f"Expected --priYl colour for NC cursor, got {color}")
|
||||
|
||||
# ── Mouse-off: anchor class removed, portal float gone ────────────
|
||||
ActionChains(browser2).move_to_element(
|
||||
browser2.find_element(By.CSS_SELECTOR, ".sig-stage")
|
||||
).perform()
|
||||
self.wait_for(
|
||||
lambda: not self.browser.find_elements(
|
||||
By.CSS_SELECTOR, mid_cursor_sel + ".active"
|
||||
)
|
||||
)
|
||||
|
||||
# Active seat advances to NC in both browsers
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat.active[data-role='NC']"
|
||||
))
|
||||
self.wait_for(lambda: self.browser2.find_element(
|
||||
By.CSS_SELECTOR, ".table-seat.active[data-role='NC']"
|
||||
))
|
||||
finally:
|
||||
self.browser2.quit()
|
||||
browser2.quit()
|
||||
|
||||
# ── SC2: NC reserves → PC sees card border coloured --priYl ──────────── #
|
||||
|
||||
@tag('channels')
|
||||
def test_nc_reservation_glows_priYl_in_pc_browser(self):
|
||||
"""
|
||||
When NC (levity mid) clicks OK on a card, PC must see that card's border
|
||||
coloured --priYl (rgb 255 207 52) via the data-reserved-by CSS selector.
|
||||
Verifies: sig_reserve view → WS broadcast → applyReservation → CSS glow.
|
||||
"""
|
||||
room, gamers = self._setup_sig_select_room()
|
||||
room_url = self.live_server_url + f"/gameboard/room/{room.pk}/"
|
||||
|
||||
# ── Browser 1: PC (founder) ───────────────────────────────────────────
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(room_url)
|
||||
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||
|
||||
# ── Browser 2: NC (amigo) ─────────────────────────────────────────────
|
||||
browser2 = self._make_browser2("amigo@test.io")
|
||||
try:
|
||||
browser2.get(room_url)
|
||||
self.wait_for(lambda: browser2.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||
|
||||
# Get first card in B2's deck
|
||||
first_card = browser2.find_element(By.CSS_SELECTOR, ".sig-card")
|
||||
card_id = first_card.get_attribute("data-card-id")
|
||||
|
||||
# Click card body → .sig-focused → OK button appears
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
ActionChains(browser2).move_to_element(first_card).perform()
|
||||
first_card.click()
|
||||
|
||||
ok_btn = self.wait_for(
|
||||
lambda: browser2.find_element(By.CSS_SELECTOR, ".sig-focused .sig-ok-btn")
|
||||
)
|
||||
ok_btn.click()
|
||||
|
||||
# ── B1 should see the card's border turn --priYl ──────────────────
|
||||
reserved_card_sel = f'.sig-card[data-card-id="{card_id}"]'
|
||||
self.wait_for(
|
||||
lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, reserved_card_sel + '[data-reserved-by="NC"]'
|
||||
)
|
||||
)
|
||||
|
||||
reserved_card = self.browser.find_element(By.CSS_SELECTOR, reserved_card_sel)
|
||||
box_shadow = self.browser.execute_script(
|
||||
"return window.getComputedStyle(arguments[0]).boxShadow",
|
||||
reserved_card,
|
||||
)
|
||||
self.assertIn(
|
||||
"255, 207, 52", box_shadow,
|
||||
f"Expected --priYl (255,207,52) in box-shadow for NC reservation, got {box_shadow}",
|
||||
)
|
||||
|
||||
finally:
|
||||
browser2.quit()
|
||||
|
||||
|
||||
|
||||
# ── Polarity theming: qualifier text + no correspondence ─────────────────────
|
||||
|
||||
class SigSelectThemeTest(FunctionalTest):
|
||||
"""Polarity-qualifier display (Graven/Leavened) and correspondence suppression.
|
||||
No WebSocket needed — stage updates are local; uses plain FunctionalTest."""
|
||||
|
||||
EMAILS = [
|
||||
"founder@test.io", "amigo@test.io", "bud@test.io",
|
||||
"pal@test.io", "dude@test.io", "bro@test.io",
|
||||
]
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.browser.set_window_size(800, 1200)
|
||||
Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
|
||||
Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"})
|
||||
|
||||
def _setup_sig_room(self):
|
||||
founder, _ = User.objects.get_or_create(email=self.EMAILS[0])
|
||||
room = Room.objects.create(name="Theme Test", owner=founder)
|
||||
_fill_room_via_orm(room, self.EMAILS)
|
||||
_assign_all_roles(room)
|
||||
return room
|
||||
|
||||
def _hover_card(self, css):
|
||||
from selenium.webdriver.common.action_chains import ActionChains
|
||||
card = self.browser.find_element(By.CSS_SELECTOR, css)
|
||||
ActionChains(self.browser).move_to_element(card).perform()
|
||||
return card
|
||||
|
||||
# ── ST1: Levity (Leavened) qualifier ──────────────────────────────────── #
|
||||
|
||||
def test_levity_non_major_card_shows_leavened_above(self):
|
||||
"""Hovering a non-major card in the levity overlay shows 'Leavened' in
|
||||
qualifier-above and nothing in qualifier-below."""
|
||||
room = self._setup_sig_room()
|
||||
self.create_pre_authenticated_session("founder@test.io") # PC = levity
|
||||
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||
|
||||
self._hover_card('.sig-card[data-arcana="Middle Arcana"]')
|
||||
|
||||
above = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-above")
|
||||
)
|
||||
self.assertEqual(above.text, "Leavened")
|
||||
below = self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-below")
|
||||
self.assertEqual(below.text, "")
|
||||
|
||||
def test_levity_major_card_shows_leavened_below(self):
|
||||
"""Hovering a major arcana card in the levity overlay shows 'Leavened' in
|
||||
qualifier-below and nothing in qualifier-above."""
|
||||
room = self._setup_sig_room()
|
||||
self.create_pre_authenticated_session("founder@test.io") # PC = levity
|
||||
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||
|
||||
self._hover_card('.sig-card[data-arcana="Major Arcana"]')
|
||||
|
||||
below = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-below")
|
||||
)
|
||||
self.assertEqual(below.text, "Leavened")
|
||||
above = self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-above")
|
||||
self.assertEqual(above.text, "")
|
||||
|
||||
# ── ST2: Gravity (Graven) qualifier ───────────────────────────────────── #
|
||||
|
||||
def test_gravity_non_major_card_shows_graven_above(self):
|
||||
"""EC (bud) sees the gravity overlay; hovering a non-major card shows 'Graven'."""
|
||||
room = self._setup_sig_room()
|
||||
self.create_pre_authenticated_session("bud@test.io") # EC = gravity
|
||||
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||
|
||||
self._hover_card('.sig-card[data-arcana="Middle Arcana"]')
|
||||
|
||||
above = self.wait_for(
|
||||
lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-qualifier-above")
|
||||
)
|
||||
self.assertEqual(above.text, "Graven")
|
||||
|
||||
# ── ST3: Correspondence not shown ─────────────────────────────────────── #
|
||||
|
||||
def test_correspondence_not_shown_in_sig_select(self):
|
||||
"""The Minchiate-equivalence field must always be blank on the stage card."""
|
||||
room = self._setup_sig_room()
|
||||
self.create_pre_authenticated_session("founder@test.io")
|
||||
self.browser.get(self.live_server_url + f"/gameboard/room/{room.pk}/")
|
||||
self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".sig-overlay"))
|
||||
|
||||
# Hover any card — correspondence should remain empty regardless
|
||||
self._hover_card(".sig-card")
|
||||
self.wait_for(lambda: self.browser.find_element(
|
||||
By.CSS_SELECTOR, ".sig-stage-card"
|
||||
))
|
||||
corr = self.browser.find_element(By.CSS_SELECTOR, ".fan-card-correspondence")
|
||||
self.assertEqual(corr.text, "")
|
||||
|
||||
608
src/static/tests/SigSelectSpec.js
Normal file
608
src/static/tests/SigSelectSpec.js
Normal file
@@ -0,0 +1,608 @@
|
||||
describe("SigSelect", () => {
|
||||
let testDiv, stageCard, card, statBlock;
|
||||
|
||||
function makeFixture({ reservations = '{}', cardCautions = '[]', polarity = 'levity', userRole = 'PC' } = {}) {
|
||||
testDiv = document.createElement("div");
|
||||
testDiv.innerHTML = `
|
||||
<div class="sig-overlay"
|
||||
data-polarity="${polarity}"
|
||||
data-user-role="${userRole}"
|
||||
data-reserve-url="/epic/room/test/sig-reserve"
|
||||
data-reservations="${reservations.replace(/"/g, '"')}">
|
||||
<div class="sig-modal">
|
||||
<div class="sig-stage">
|
||||
<div class="sig-stage-card" style="display:none">
|
||||
<span class="fan-corner-rank"></span>
|
||||
<i class="stage-suit-icon"></i>
|
||||
<p class="fan-card-name-group"></p>
|
||||
<p class="sig-qualifier-above"></p>
|
||||
<h3 class="fan-card-name"></h3>
|
||||
<p class="sig-qualifier-below"></p>
|
||||
<p class="fan-card-arcana"></p>
|
||||
<p class="fan-card-correspondence"></p>
|
||||
</div>
|
||||
<div class="sig-stat-block">
|
||||
<button class="btn btn-reverse sig-flip-btn" type="button">FLIP</button>
|
||||
<button class="btn btn-caution sig-caution-btn" type="button">!!</button>
|
||||
<div class="stat-face stat-face--upright">
|
||||
<p class="stat-face-label">Upright</p>
|
||||
<ul class="stat-keywords" id="id_stat_keywords_upright"></ul>
|
||||
</div>
|
||||
<div class="stat-face stat-face--reversed">
|
||||
<p class="stat-face-label">Reversed</p>
|
||||
<ul class="stat-keywords" id="id_stat_keywords_reversed"></ul>
|
||||
</div>
|
||||
<button class="btn btn-nav-left sig-caution-prev" type="button">◀</button>
|
||||
<button class="btn btn-nav-right sig-caution-next" type="button">▶</button>
|
||||
<div class="sig-caution-tooltip" id="id_sig_caution">
|
||||
<div class="sig-caution-header">
|
||||
<h4 class="sig-caution-title">Caution!</h4>
|
||||
<span class="sig-caution-type">Rival Interaction</span>
|
||||
</div>
|
||||
<p class="sig-caution-shoptalk">[Shoptalk forthcoming]</p>
|
||||
<p class="sig-caution-effect"></p>
|
||||
<span class="sig-caution-index"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sig-deck-grid">
|
||||
<div class="sig-card"
|
||||
data-card-id="42"
|
||||
data-corner-rank="K"
|
||||
data-suit-icon=""
|
||||
data-name-group="Pentacles"
|
||||
data-name-title="King of Pentacles"
|
||||
data-arcana="Minor Arcana"
|
||||
data-correspondence=""
|
||||
data-keywords-upright="action,impulsiveness,ambition"
|
||||
data-keywords-reversed="no direction,disregard for consequences"
|
||||
data-cautions="${cardCautions.replace(/"/g, '"')}">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">K</span>
|
||||
</div>
|
||||
<div class="sig-card-actions">
|
||||
<button class="sig-ok-btn btn btn-confirm">OK</button>
|
||||
<button class="sig-nvm-btn btn btn-cancel">NVM</button>
|
||||
</div>
|
||||
<div class="sig-card-cursors">
|
||||
<span class="sig-cursor sig-cursor--left"></span>
|
||||
<span class="sig-cursor sig-cursor--mid"></span>
|
||||
<span class="sig-cursor sig-cursor--right"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(testDiv);
|
||||
stageCard = testDiv.querySelector(".sig-stage-card");
|
||||
statBlock = testDiv.querySelector(".sig-stat-block");
|
||||
card = testDiv.querySelector(".sig-card");
|
||||
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
||||
Promise.resolve({ ok: true })
|
||||
);
|
||||
window._roomSocket = { readyState: -1, send: jasmine.createSpy("send") };
|
||||
SigSelect._testInit();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (testDiv) testDiv.remove();
|
||||
delete window._roomSocket;
|
||||
});
|
||||
|
||||
// ── Stage reveal on mouseenter ─────────────────────────────────────── //
|
||||
|
||||
describe("stage preview", () => {
|
||||
beforeEach(() => makeFixture());
|
||||
|
||||
it("shows the stage card on mouseenter", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(stageCard.style.display).toBe("");
|
||||
});
|
||||
|
||||
it("hides the stage card on mouseleave when not frozen", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||
expect(stageCard.style.display).toBe("none");
|
||||
});
|
||||
|
||||
it("does NOT hide the stage card on mouseleave when frozen (reserved)", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
SigSelect._setFrozen(true);
|
||||
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||
expect(stageCard.style.display).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Card focus (click → OK overlay) ───────────────────────────────── //
|
||||
|
||||
describe("card click", () => {
|
||||
beforeEach(() => makeFixture());
|
||||
|
||||
it("adds .sig-focused to the clicked card", () => {
|
||||
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(card.classList.contains("sig-focused")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the stage card after click", () => {
|
||||
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(stageCard.style.display).toBe("");
|
||||
});
|
||||
|
||||
it("does not focus a card reserved by another role", () => {
|
||||
card.dataset.reservedBy = "NC";
|
||||
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(card.classList.contains("sig-focused")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Lock after reservation ─────────────────────────────────────────── //
|
||||
|
||||
describe("lock after reservation", () => {
|
||||
beforeEach(() => makeFixture());
|
||||
|
||||
it("does not focus another card while one is reserved", () => {
|
||||
// Simulate a reservation on some other card (not this one)
|
||||
SigSelect._setReservedCardId("99");
|
||||
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(card.classList.contains("sig-focused")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not call fetch when OK is clicked while a different card is reserved", () => {
|
||||
SigSelect._setReservedCardId("99");
|
||||
var okBtn = card.querySelector(".sig-ok-btn");
|
||||
okBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(window.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows focus again after reservation is cleared", () => {
|
||||
SigSelect._setReservedCardId("99");
|
||||
SigSelect._setReservedCardId(null);
|
||||
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(card.classList.contains("sig-focused")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── WS release clears NVM in a second browser ─────────────────────── //
|
||||
// Simulates the same gamer having two tabs open: tab B must clear its
|
||||
// .sig-reserved--own when tab A presses NVM (WS release event arrives).
|
||||
// The release payload must carry the card_id so the JS can find the element.
|
||||
|
||||
describe("WS release event (second-browser NVM sync)", () => {
|
||||
beforeEach(() => makeFixture({ reservations: '{"42":"PC"}' }));
|
||||
|
||||
it("removes .sig-reserved and .sig-reserved--own on WS release", () => {
|
||||
// Confirm reservation was applied on init
|
||||
expect(card.classList.contains("sig-reserved--own")).toBe(true);
|
||||
expect(card.classList.contains("sig-reserved")).toBe(true);
|
||||
|
||||
// Tab A presses NVM — tab B receives this WS event with the card_id
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "PC", reserved: false },
|
||||
}));
|
||||
|
||||
expect(card.classList.contains("sig-reserved--own")).toBe(false);
|
||||
expect(card.classList.contains("sig-reserved")).toBe(false);
|
||||
});
|
||||
|
||||
it("unfreezes the stage so other cards can be focused after WS release", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "PC", reserved: false },
|
||||
}));
|
||||
|
||||
// Should now be able to click the card body again
|
||||
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(card.classList.contains("sig-focused")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Caution tooltip (!!) ──────────────────────────────────────────── //
|
||||
|
||||
describe("caution tooltip", () => {
|
||||
var cautionTooltip, cautionEffect, cautionPrev, cautionNext, cautionBtn;
|
||||
|
||||
beforeEach(() => {
|
||||
makeFixture();
|
||||
cautionTooltip = testDiv.querySelector(".sig-caution-tooltip");
|
||||
cautionEffect = testDiv.querySelector(".sig-caution-effect");
|
||||
cautionPrev = testDiv.querySelector(".sig-caution-prev");
|
||||
cautionNext = testDiv.querySelector(".sig-caution-next");
|
||||
cautionBtn = testDiv.querySelector(".sig-caution-btn");
|
||||
});
|
||||
|
||||
function hover() {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
}
|
||||
|
||||
function openCaution() {
|
||||
hover();
|
||||
cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
|
||||
it("!! click adds .sig-caution-open to the stage", () => {
|
||||
openCaution();
|
||||
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true);
|
||||
});
|
||||
|
||||
it("FYI click when btn-disabled does not close caution", () => {
|
||||
openCaution();
|
||||
expect(cautionBtn.classList.contains("btn-disabled")).toBe(true);
|
||||
cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows placeholder text when cautions list is empty", () => {
|
||||
card.dataset.cautions = "[]";
|
||||
openCaution();
|
||||
expect(cautionEffect.innerHTML).toContain("pending");
|
||||
});
|
||||
|
||||
it("renders first caution effect HTML including .card-ref spans", () => {
|
||||
card.dataset.cautions = JSON.stringify(['First <span class="card-ref">Card</span> effect.']);
|
||||
openCaution();
|
||||
expect(cautionEffect.querySelector(".card-ref")).not.toBeNull();
|
||||
expect(cautionEffect.querySelector(".card-ref").textContent).toBe("Card");
|
||||
});
|
||||
|
||||
it("with 1 caution both nav arrows are disabled", () => {
|
||||
card.dataset.cautions = JSON.stringify(["Single caution."]);
|
||||
openCaution();
|
||||
expect(cautionPrev.disabled).toBe(true);
|
||||
expect(cautionNext.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("with multiple cautions both nav arrows are always enabled", () => {
|
||||
card.dataset.cautions = JSON.stringify(["C1", "C2", "C3", "C4"]);
|
||||
openCaution();
|
||||
expect(cautionPrev.disabled).toBe(false);
|
||||
expect(cautionNext.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("next click advances to second caution", () => {
|
||||
card.dataset.cautions = JSON.stringify(["First", "Second"]);
|
||||
openCaution();
|
||||
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(cautionEffect.innerHTML).toContain("Second");
|
||||
});
|
||||
|
||||
it("next wraps from last caution back to first", () => {
|
||||
card.dataset.cautions = JSON.stringify(["First", "Last"]);
|
||||
openCaution();
|
||||
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(cautionEffect.innerHTML).toContain("First");
|
||||
});
|
||||
|
||||
it("prev click goes back to first caution", () => {
|
||||
card.dataset.cautions = JSON.stringify(["First", "Second"]);
|
||||
openCaution();
|
||||
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
cautionPrev.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(cautionEffect.innerHTML).toContain("First");
|
||||
});
|
||||
|
||||
it("prev wraps from first caution to last", () => {
|
||||
card.dataset.cautions = JSON.stringify(["First", "Middle", "Last"]);
|
||||
openCaution();
|
||||
cautionPrev.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(cautionEffect.innerHTML).toContain("Last");
|
||||
});
|
||||
|
||||
it("index label shows n / total when multiple cautions", () => {
|
||||
card.dataset.cautions = JSON.stringify(["C1", "C2", "C3"]);
|
||||
openCaution();
|
||||
expect(testDiv.querySelector(".sig-caution-index").textContent).toBe("1 / 3");
|
||||
});
|
||||
|
||||
it("index label is empty when only 1 caution", () => {
|
||||
card.dataset.cautions = JSON.stringify(["Only one."]);
|
||||
openCaution();
|
||||
expect(testDiv.querySelector(".sig-caution-index").textContent).toBe("");
|
||||
});
|
||||
|
||||
it("card mouseleave closes the caution", () => {
|
||||
openCaution();
|
||||
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true);
|
||||
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false);
|
||||
});
|
||||
|
||||
it("opening again resets to first caution", () => {
|
||||
card.dataset.cautions = JSON.stringify(["First", "Second"]);
|
||||
openCaution();
|
||||
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
// Close and reopen
|
||||
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||
openCaution();
|
||||
expect(cautionEffect.innerHTML).toContain("First");
|
||||
});
|
||||
|
||||
it("opening caution adds .btn-disabled and swaps labels to ×", () => {
|
||||
openCaution();
|
||||
var flipBtn = testDiv.querySelector(".sig-flip-btn");
|
||||
expect(flipBtn.classList.contains("btn-disabled")).toBe(true);
|
||||
expect(cautionBtn.classList.contains("btn-disabled")).toBe(true);
|
||||
expect(flipBtn.textContent).toBe("\u00D7");
|
||||
expect(cautionBtn.textContent).toBe("\u00D7");
|
||||
});
|
||||
|
||||
it("closing caution removes .btn-disabled and restores original labels", () => {
|
||||
var flipBtn = testDiv.querySelector(".sig-flip-btn");
|
||||
var origFlip = flipBtn.textContent;
|
||||
var origCaution = cautionBtn.textContent;
|
||||
openCaution();
|
||||
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||
expect(flipBtn.classList.contains("btn-disabled")).toBe(false);
|
||||
expect(cautionBtn.classList.contains("btn-disabled")).toBe(false);
|
||||
expect(flipBtn.textContent).toBe(origFlip);
|
||||
expect(cautionBtn.textContent).toBe(origCaution);
|
||||
});
|
||||
|
||||
it("clicking the tooltip closes caution", () => {
|
||||
openCaution();
|
||||
cautionEffect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false);
|
||||
});
|
||||
|
||||
it("FLIP click when caution open (btn-disabled) does nothing", () => {
|
||||
openCaution();
|
||||
var flipBtn = testDiv.querySelector(".sig-flip-btn");
|
||||
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true);
|
||||
expect(statBlock.classList.contains("is-reversed")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Stat block: keyword population and FLIP toggle ────────────────── //
|
||||
|
||||
describe("stat block and FLIP", () => {
|
||||
beforeEach(() => makeFixture());
|
||||
|
||||
it("populates upright keywords when a card is hovered", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
var items = statBlock.querySelectorAll("#id_stat_keywords_upright li");
|
||||
expect(items.length).toBe(3);
|
||||
expect(items[0].textContent).toBe("action");
|
||||
expect(items[1].textContent).toBe("impulsiveness");
|
||||
expect(items[2].textContent).toBe("ambition");
|
||||
});
|
||||
|
||||
it("populates reversed keywords when a card is hovered", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
var items = statBlock.querySelectorAll("#id_stat_keywords_reversed li");
|
||||
expect(items.length).toBe(2);
|
||||
expect(items[0].textContent).toBe("no direction");
|
||||
expect(items[1].textContent).toBe("disregard for consequences");
|
||||
});
|
||||
|
||||
it("FLIP click adds .is-reversed to the stat block", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
var flipBtn = statBlock.querySelector(".sig-flip-btn");
|
||||
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(statBlock.classList.contains("is-reversed")).toBe(true);
|
||||
});
|
||||
|
||||
it("second FLIP click removes .is-reversed", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
var flipBtn = statBlock.querySelector(".sig-flip-btn");
|
||||
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(statBlock.classList.contains("is-reversed")).toBe(false);
|
||||
});
|
||||
|
||||
it("hovering a new card resets .is-reversed", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
statBlock.querySelector(".sig-flip-btn").dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true })
|
||||
);
|
||||
expect(statBlock.classList.contains("is-reversed")).toBe(true);
|
||||
|
||||
// Leave and re-enter (simulates moving to a different card)
|
||||
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(statBlock.classList.contains("is-reversed")).toBe(false);
|
||||
});
|
||||
|
||||
it("card with no keywords yields empty lists", () => {
|
||||
card.dataset.keywordsUpright = "";
|
||||
card.dataset.keywordsReversed = "";
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(statBlock.querySelectorAll("#id_stat_keywords_upright li").length).toBe(0);
|
||||
expect(statBlock.querySelectorAll("#id_stat_keywords_reversed li").length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── WS cursor hover (applyHover) ──────────────────────────────────────── //
|
||||
//
|
||||
// Fixture polarity = levity, userRole = PC.
|
||||
// POLARITY_ROLES: levity → [PC, NC, SC] = [left, mid, right]
|
||||
//
|
||||
// Only tests the JS position mapping — colour is CSS-only.
|
||||
|
||||
describe("WS cursor hover", () => {
|
||||
beforeEach(() => makeFixture());
|
||||
|
||||
it("NC hover activates the --mid cursor", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||
detail: { card_id: 42, role: "NC", active: true },
|
||||
}));
|
||||
expect(card.querySelector(".sig-cursor--mid").classList.contains("active")).toBe(true);
|
||||
});
|
||||
|
||||
it("SC hover activates the --right cursor", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||
detail: { card_id: 42, role: "SC", active: true },
|
||||
}));
|
||||
expect(card.querySelector(".sig-cursor--right").classList.contains("active")).toBe(true);
|
||||
});
|
||||
|
||||
it("own role (PC) hover event is ignored — no cursor activates", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||
detail: { card_id: 42, role: "PC", active: true },
|
||||
}));
|
||||
expect(card.querySelectorAll(".sig-cursor.active").length).toBe(0);
|
||||
});
|
||||
|
||||
it("hover-off removes .active from the cursor", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||
detail: { card_id: 42, role: "NC", active: true },
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||
detail: { card_id: 42, role: "NC", active: false },
|
||||
}));
|
||||
expect(card.querySelector(".sig-cursor--mid").classList.contains("active")).toBe(false);
|
||||
});
|
||||
|
||||
it("hover on unknown card_id is a no-op", () => {
|
||||
expect(() => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||
detail: { card_id: 9999, role: "NC", active: true },
|
||||
}));
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── WS reservation — data-reserved-by attribute ───────────────────────── //
|
||||
//
|
||||
// applyReservation() sets data-reserved-by so the CSS can glow the card in
|
||||
// the reserving gamer's role colour. These tests assert the attribute, not
|
||||
// the colour (CSS variables aren't resolvable in the SpecRunner context).
|
||||
|
||||
describe("WS reservation sets data-reserved-by", () => {
|
||||
beforeEach(() => makeFixture());
|
||||
|
||||
it("peer reservation sets data-reserved-by to the reserving role", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "NC", reserved: true },
|
||||
}));
|
||||
expect(card.dataset.reservedBy).toBe("NC");
|
||||
});
|
||||
|
||||
it("peer reservation also adds .sig-reserved class", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "NC", reserved: true },
|
||||
}));
|
||||
expect(card.classList.contains("sig-reserved")).toBe(true);
|
||||
});
|
||||
|
||||
it("release removes data-reserved-by", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "NC", reserved: true },
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "NC", reserved: false },
|
||||
}));
|
||||
expect(card.dataset.reservedBy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("own reservation (PC) sets data-reserved-by AND .sig-reserved--own", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "PC", reserved: true },
|
||||
}));
|
||||
expect(card.dataset.reservedBy).toBe("PC");
|
||||
expect(card.classList.contains("sig-reserved--own")).toBe(true);
|
||||
});
|
||||
|
||||
it("peer reservation places a thumbs-up float and removes any hand-pointer float", () => {
|
||||
// First, a hover float exists for NC (mid cursor)
|
||||
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||
detail: { card_id: 42, role: "NC", active: true },
|
||||
}));
|
||||
expect(document.querySelector('.sig-cursor-float[data-role="NC"]')).not.toBeNull();
|
||||
expect(document.querySelector('.fa-hand-pointer[data-role="NC"]')).not.toBeNull();
|
||||
|
||||
// NC then clicks OK — reservation arrives
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "NC", reserved: true },
|
||||
}));
|
||||
|
||||
// Thumbs-up replaces hand-pointer
|
||||
const floatEl = document.querySelector('.sig-cursor-float[data-role="NC"]');
|
||||
expect(floatEl).not.toBeNull();
|
||||
expect(floatEl.classList.contains("fa-thumbs-up")).toBe(true);
|
||||
expect(floatEl.classList.contains("fa-hand-pointer")).toBe(false);
|
||||
});
|
||||
|
||||
it("peer release removes the thumbs-up float", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "NC", reserved: true },
|
||||
}));
|
||||
expect(document.querySelector('.sig-cursor-float--reserved[data-role="NC"]')).not.toBeNull();
|
||||
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "NC", reserved: false },
|
||||
}));
|
||||
expect(document.querySelector('.sig-cursor-float--reserved[data-role="NC"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Polarity theming — stage qualifier text ────────────────────────────── //
|
||||
//
|
||||
// On mouseenter, updateStage() injects "Leavened" or "Graven" into the
|
||||
// sig-qualifier-above (non-major) or sig-qualifier-below (major arcana) slot.
|
||||
// Correspondence field is never populated in sig-select context.
|
||||
|
||||
describe("polarity theming — stage qualifier", () => {
|
||||
it("levity non-major card puts 'Leavened' in qualifier-above, qualifier-below empty", () => {
|
||||
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||
// data-arcana defaults to "Minor Arcana" in fixture → non-major
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Leavened");
|
||||
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("");
|
||||
});
|
||||
|
||||
it("levity major arcana card puts 'Leavened' in qualifier-below, qualifier-above empty", () => {
|
||||
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||
card.dataset.arcana = "Major Arcana";
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
|
||||
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened");
|
||||
});
|
||||
|
||||
it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => {
|
||||
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||
card.dataset.arcana = "Major Arcana";
|
||||
card.dataset.nameTitle = "The Schizo";
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".fan-card-name").textContent).toBe("The Schizo,");
|
||||
});
|
||||
|
||||
it("non-major arcana title has no trailing comma", () => {
|
||||
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||
// fixture default: Minor Arcana, "King of Pentacles"
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".fan-card-name").textContent).toBe("King of Pentacles");
|
||||
});
|
||||
|
||||
it("gravity non-major card puts 'Graven' in qualifier-above", () => {
|
||||
makeFixture({ polarity: 'gravity', userRole: 'BC' });
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Graven");
|
||||
});
|
||||
|
||||
it("gravity major arcana card puts 'Graven' in qualifier-below", () => {
|
||||
makeFixture({ polarity: 'gravity', userRole: 'BC' });
|
||||
card.dataset.arcana = "Major Arcana";
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Graven");
|
||||
});
|
||||
|
||||
it("hovering clears qualifier slots from the previous card", () => {
|
||||
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
card.dataset.arcana = "Major Arcana";
|
||||
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
// Now major — above should be empty, below filled
|
||||
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
|
||||
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened");
|
||||
});
|
||||
|
||||
it("correspondence field is never populated", () => {
|
||||
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||
card.dataset.correspondence = "Il Bagatto (Minchiate)";
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".fan-card-correspondence").textContent).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,10 +21,12 @@
|
||||
<script src="Spec.js"></script>
|
||||
<script src="RoleSelectSpec.js"></script>
|
||||
<script src="TraySpec.js"></script>
|
||||
<script src="SigSelectSpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
<script src="/static/apps/epic/role-select.js"></script>
|
||||
<script src="/static/apps/epic/tray.js"></script>
|
||||
<script src="/static/apps/epic/sig-select.js"></script>
|
||||
<!-- Jasmine env config (optional) -->
|
||||
<script src="lib/jasmine-6.0.1/boot1.js"></script>
|
||||
|
||||
|
||||
@@ -110,7 +110,8 @@
|
||||
}
|
||||
|
||||
// In landscape: shift gear btn and applet menus left of the footer right sidebar
|
||||
@media (orientation: landscape) and (max-width: 1440px) {
|
||||
// XL override below doubles sidebar to 8rem — centre items in the wider column.
|
||||
@media (orientation: landscape) {
|
||||
$sidebar-w: 4rem;
|
||||
|
||||
.gameboard-page,
|
||||
@@ -119,8 +120,8 @@
|
||||
.room-page,
|
||||
.billboard-page {
|
||||
> .gear-btn {
|
||||
right: calc(#{$sidebar-w} + 0.5rem);
|
||||
bottom: 4.2rem; // same gap above kit btn as portrait; no page-specific overrides needed
|
||||
right: 1rem;
|
||||
bottom: 3.95rem; // same gap above kit btn as portrait; no page-specific overrides needed
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
@@ -129,13 +130,32 @@
|
||||
#id_game_applet_menu,
|
||||
#id_game_kit_menu,
|
||||
#id_wallet_applet_menu,
|
||||
#id_room_menu,
|
||||
#id_billboard_applet_menu {
|
||||
right: calc(#{$sidebar-w} + 1rem);
|
||||
bottom: 6.6rem; // same as portrait, just shifted right of footer sidebar
|
||||
right: 1rem;
|
||||
bottom: 6.6rem;
|
||||
top: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (min-width: 1800px) {
|
||||
// Centre gear btn and menus in the doubled 8rem sidebar (was 0.5rem from right edge)
|
||||
.gameboard-page,
|
||||
.dashboard-page,
|
||||
.wallet-page,
|
||||
.room-page,
|
||||
.billboard-page {
|
||||
> .gear-btn { right: 2.5rem; }
|
||||
}
|
||||
|
||||
#id_dash_applet_menu,
|
||||
#id_game_applet_menu,
|
||||
#id_game_kit_menu,
|
||||
#id_wallet_applet_menu,
|
||||
#id_room_menu,
|
||||
#id_billboard_applet_menu { right: 2.5rem; }
|
||||
}
|
||||
|
||||
// ── Applet box visual shell (reusable outside the grid) ────
|
||||
%applet-box {
|
||||
border:
|
||||
@@ -214,6 +234,16 @@
|
||||
black 99%,
|
||||
transparent 100%
|
||||
);
|
||||
margin-left: 1rem;
|
||||
margin-top: 1rem;
|
||||
@media (orientation: landscape) and (min-width: 900px) {
|
||||
margin-left: 2rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
@media (orientation: landscape) and (min-width: 1800px) {
|
||||
margin-left: 4rem;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
section {
|
||||
@extend %applet-box;
|
||||
|
||||
@@ -35,13 +35,26 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.container-fluid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-right: 0.5rem;
|
||||
|
||||
> form { flex-shrink: 0; margin-left: auto; }
|
||||
.navbar-user {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
.navbar-text { flex: none; } // prevent expansion; BYE abuts the spans
|
||||
> form { flex-shrink: 0; order: -1; } // BYE left of spans
|
||||
}
|
||||
|
||||
> #id_cont_game { flex-shrink: 0; }
|
||||
}
|
||||
|
||||
.navbar-text,
|
||||
@@ -180,8 +193,18 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (max-width: 1440px) {
|
||||
$sidebar-w: 4rem;
|
||||
@media (orientation: landscape) and (max-width: 1100px) {
|
||||
body .container {
|
||||
.navbar {
|
||||
h1 {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
$sidebar-w: 5rem;
|
||||
|
||||
// ── Sidebar layout: navbar ← left, footer → right ────────────────────────────
|
||||
body {
|
||||
@@ -199,7 +222,7 @@ body {
|
||||
border-bottom: none;
|
||||
border-right: 0.1rem solid rgba(var(--secUser), 0.4);
|
||||
background-color: rgba(var(--priUser), 1);
|
||||
z-index: 300;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
|
||||
.container-fluid {
|
||||
@@ -210,8 +233,17 @@ body {
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0 0.25rem;
|
||||
margin: 0; // reset portrait margin-right: 0.5rem so container fills full sidebar width
|
||||
|
||||
> form { flex-shrink: 0; order: -1; } // logout above brand
|
||||
> #id_cont_game { flex-shrink: 0; order: -1; } // cont-game above brand
|
||||
|
||||
.navbar-user {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
.navbar-text { margin: 0; } // cancel landscape margin:auto so BYE abuts
|
||||
> form { order: 0; .btn { margin-top: 0; } } // abut spans
|
||||
}
|
||||
}
|
||||
|
||||
.navbar-brand h1 {
|
||||
@@ -226,6 +258,7 @@ body {
|
||||
.navbar-brand {
|
||||
order: 1; // brand at bottom
|
||||
width: 100%;
|
||||
margin-left: 0; // reset portrait margin-left: 1rem
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -242,13 +275,12 @@ body {
|
||||
.navbar-label { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
font-size: 0.75rem;
|
||||
border-width: 0.125rem;
|
||||
// margin-left: 0.75rem;
|
||||
}
|
||||
// .btn-primary {
|
||||
// width: 4rem;
|
||||
// height: 4rem;
|
||||
// font-size: 0.875rem;
|
||||
// border-width: 0.21rem;
|
||||
// }
|
||||
|
||||
// Login form: offset from fixed sidebars in landscape
|
||||
.input-group {
|
||||
@@ -266,26 +298,35 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
// Container: fill center, compensate for fixed sidebars on both sides
|
||||
// Container: fill center, compensate for fixed sidebars on both sides.
|
||||
// max-width: none overrides the @media (min-width: 1200px) rule above so the
|
||||
// container fills all available space between the two sidebars on wide screens.
|
||||
body .container {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
margin-left: $sidebar-w;
|
||||
margin-right: $sidebar-w;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
// Header row: compact in landscape
|
||||
// Header row: h2 rotates into the left gutter (just right of the navbar border).
|
||||
// position:fixed takes h2 out of flow; .row collapses to zero height automatically.
|
||||
body .container .row {
|
||||
padding: 0.25rem 0;
|
||||
|
||||
.col-lg-6 h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 0.25rem;
|
||||
letter-spacing: 0.4em;
|
||||
text-align: center;
|
||||
text-align-last: left;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
body .container .row .col-lg-6 h2 {
|
||||
position: fixed;
|
||||
left: 5rem; // $sidebar-w — flush with the navbar right border
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(180deg);
|
||||
writing-mode: vertical-rl;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: 0.4em;
|
||||
margin: 0;
|
||||
z-index: 85;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Footer → fixed right sidebar (mirrors navbar approach — explicit right boundary)
|
||||
@@ -302,14 +343,17 @@ body {
|
||||
align-items: center;
|
||||
border-top: none;
|
||||
border-left: 0.1rem solid rgba(var(--secUser), 0.3);
|
||||
background-color: rgba(var(--priUser), 1); // opaque: masks tray sliding behind it
|
||||
padding: 1rem 0;
|
||||
gap: 0;
|
||||
z-index: 100;
|
||||
|
||||
#id_footer_nav {
|
||||
flex-direction: column-reverse;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
gap: 3rem;
|
||||
gap: 1.5rem !important;
|
||||
margin-bottom: 4rem;
|
||||
|
||||
a {
|
||||
font-size: 1.75rem;
|
||||
@@ -321,13 +365,104 @@ body {
|
||||
|
||||
.footer-container {
|
||||
position: absolute;
|
||||
bottom: 0.75rem;
|
||||
top: 0.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.55rem;
|
||||
line-height: 1.4;
|
||||
color: rgba(var(--secUser), 0.5);
|
||||
line-height: 0.75 !important;
|
||||
color: rgba(var(--secUser), 1);
|
||||
|
||||
br { display: block; }
|
||||
|
||||
small {
|
||||
font-size: 0.75rem !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (min-width: 700px) {
|
||||
body .container .row .col-lg-6 h2 {
|
||||
@media (min-height: 400px) {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
@media (min-height: 500px) {
|
||||
font-size: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
body #id_footer {
|
||||
#id_footer_nav {
|
||||
gap: 3rem !important;
|
||||
|
||||
a {
|
||||
font-size: 1.75rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-container {
|
||||
line-height: 1;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
small {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── XL landscape (≥1800px): double sidebar widths and scale content ────────────
|
||||
@media (orientation: landscape) and (min-width: 1800px) {
|
||||
$sidebar-xl: 8rem;
|
||||
|
||||
body .container .navbar {
|
||||
width: $sidebar-xl;
|
||||
|
||||
.container-fluid {
|
||||
gap: 2rem;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.navbar-brand h1 { font-size: 2.4rem; }
|
||||
.navbar-text { font-size: 0.78rem; } // 0.65rem × 1.2
|
||||
// .btn-primary { width: 4rem; height: 4rem; font-size: 0.875rem; }
|
||||
|
||||
.input-group {
|
||||
left: $sidebar-xl;
|
||||
right: $sidebar-xl;
|
||||
}
|
||||
}
|
||||
|
||||
body .container {
|
||||
margin-left: $sidebar-xl;
|
||||
margin-right: $sidebar-xl;
|
||||
}
|
||||
|
||||
// h2 page title: keep vertical rotation; shift left to clear the wider XL navbar.
|
||||
body .container .row .col-lg-6 h2 {
|
||||
left: 8rem; // $sidebar-xl
|
||||
@media (min-height: 800px) {
|
||||
font-size: 4.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
body #id_footer {
|
||||
width: $sidebar-xl;
|
||||
|
||||
#id_footer_nav {
|
||||
gap: 8rem !important;
|
||||
a { font-size: 3rem; }
|
||||
}
|
||||
|
||||
.footer-container {
|
||||
font-size: 0.85rem;
|
||||
margin-top: 1rem;
|
||||
|
||||
|
||||
small {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -340,13 +475,6 @@ body {
|
||||
.navbar-brand h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
font-size: 0.75rem;
|
||||
border-width: 0.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.row .col-lg-6 h2 {
|
||||
@@ -413,8 +541,8 @@ body {
|
||||
br { display: none; }
|
||||
|
||||
small {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.6;
|
||||
font-size: 0.75rem;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
font-size: 0.875rem;
|
||||
border-width: 0.21rem;
|
||||
color: rgba(var(--quaUser), 1);
|
||||
border-color: rgba(var(--quaUser), 1);
|
||||
background-color: rgba(var(--quiUser), 1);
|
||||
@@ -34,37 +38,6 @@
|
||||
0.25rem 0.25rem 0.25rem rgba(var(--quiUser), 0.12)
|
||||
;
|
||||
|
||||
&:hover {
|
||||
text-shadow:
|
||||
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
|
||||
0 0 1rem rgba(var(--quaUser), 1)
|
||||
;
|
||||
box-shadow:
|
||||
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.5rem rgba(var(--quaUser), 0.12)
|
||||
;
|
||||
}
|
||||
|
||||
&:active {
|
||||
border: 0.18rem solid rgba(var(--quaUser), 1);
|
||||
text-shadow:
|
||||
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.12rem rgba(var(--quaUser), 1)
|
||||
;
|
||||
box-shadow:
|
||||
-0.1rem -0.1rem 0.12rem rgba(var(--quiUser), 0.25),
|
||||
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.5rem rgba(var(--quaUser), 0.12)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-xl {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
font-size: 0.875rem;
|
||||
border-width: 0.21rem;
|
||||
|
||||
&:hover {
|
||||
text-shadow:
|
||||
0.2rem 0.2rem 0.2rem rgba(0, 0, 0, 0.25),
|
||||
@@ -72,7 +45,7 @@
|
||||
;
|
||||
box-shadow:
|
||||
0.24rem 0.24rem 0.5rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.5rem rgba(var(--quaUser), 22)
|
||||
0 0 0.5rem rgba(var(--quaUser), 0.22)
|
||||
;
|
||||
}
|
||||
|
||||
@@ -88,6 +61,13 @@
|
||||
0 0 0.5rem rgba(var(--quaUser), 0.22)
|
||||
;
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (max-width: 1100px) {
|
||||
width: 2.75rem !important;
|
||||
height: 2.75rem !important;
|
||||
font-size: 0.625rem !important;
|
||||
border-width: 0.125rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-abandon {
|
||||
@@ -300,13 +280,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (min-width: 1800px) {
|
||||
width: 2.4rem; // 2rem × 1.2
|
||||
height: 2.4rem;
|
||||
font-size: 0.75rem; // 0.63rem × 1.2
|
||||
}
|
||||
|
||||
&.btn-disabled {
|
||||
cursor: default !important;
|
||||
pointer-events: none;
|
||||
font-size: 1.2rem;
|
||||
padding-bottom: 0.1rem;
|
||||
color: rgba(var(--secUser), 0.25);
|
||||
background-color: rgba(var(--priUser), 1);
|
||||
border-color: rgba(var(--secUser), 0.25);
|
||||
color: rgba(var(--secUser), 0.25) !important;
|
||||
background-color: rgba(var(--priUser), 1) !important;
|
||||
border-color: rgba(var(--secUser), 0.25) !important;
|
||||
box-shadow:
|
||||
0.1rem 0.1rem 0.12rem rgba(var(--priUser), 0.5),
|
||||
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||
@@ -336,4 +323,144 @@
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-nav-left {
|
||||
color: rgba(var(--priFs), 1);
|
||||
border-color: rgba(var(--priFs), 1);
|
||||
background-color: rgba(var(--terFs), 1);
|
||||
box-shadow:
|
||||
0.1rem 0.1rem 0.12rem rgba(var(--terFs), 0.25),
|
||||
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||
0.25rem 0.25rem 0.25rem rgba(var(--terFs), 0.12)
|
||||
;
|
||||
|
||||
&:hover {
|
||||
text-shadow:
|
||||
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
|
||||
0 0 1rem rgba(var(--priFs), 1)
|
||||
;
|
||||
box-shadow:
|
||||
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.5rem rgba(var(--priFs), 0.12)
|
||||
;
|
||||
}
|
||||
|
||||
&:active {
|
||||
border: 0.18rem solid rgba(var(--priFs), 1);
|
||||
text-shadow:
|
||||
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.12rem rgba(var(--priFs), 1)
|
||||
;
|
||||
box-shadow:
|
||||
-0.1rem -0.1rem 0.12rem rgba(var(--terFs), 0.25),
|
||||
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.5rem rgba(var(--priFs), 0.12)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-nav-right {
|
||||
color: rgba(var(--priLm), 1);
|
||||
border-color: rgba(var(--priLm), 1);
|
||||
background-color: rgba(var(--terLm), 1);
|
||||
box-shadow:
|
||||
0.1rem 0.1rem 0.12rem rgba(var(--terLm), 0.25),
|
||||
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||
0.25rem 0.25rem 0.25rem rgba(var(--terLm), 0.12)
|
||||
;
|
||||
|
||||
&:hover {
|
||||
text-shadow:
|
||||
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
|
||||
0 0 1rem rgba(var(--priLm), 1)
|
||||
;
|
||||
box-shadow:
|
||||
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.5rem rgba(var(--priLm), 0.12)
|
||||
;
|
||||
}
|
||||
|
||||
&:active {
|
||||
border: 0.18rem solid rgba(var(--priLm), 1);
|
||||
text-shadow:
|
||||
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.12rem rgba(var(--priLm), 1)
|
||||
;
|
||||
box-shadow:
|
||||
-0.1rem -0.1rem 0.12rem rgba(var(--terLm), 0.25),
|
||||
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.5rem rgba(var(--priLm), 0.12)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-reverse {
|
||||
color: rgba(var(--priCy), 1);
|
||||
border-color: rgba(var(--priCy), 1);
|
||||
background-color: rgba(var(--terCy), 1);
|
||||
box-shadow:
|
||||
0.1rem 0.1rem 0.12rem rgba(var(--terCy), 0.25),
|
||||
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||
0.25rem 0.25rem 0.25rem rgba(var(--terCy), 0.12)
|
||||
;
|
||||
|
||||
&:hover {
|
||||
text-shadow:
|
||||
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
|
||||
0 0 1rem rgba(var(--priCy), 1)
|
||||
;
|
||||
box-shadow:
|
||||
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.5rem rgba(var(--priCy), 0.12)
|
||||
;
|
||||
}
|
||||
|
||||
&:active {
|
||||
border: 0.18rem solid rgba(var(--priCy), 1);
|
||||
text-shadow:
|
||||
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.12rem rgba(var(--priCy), 1)
|
||||
;
|
||||
box-shadow:
|
||||
-0.1rem -0.1rem 0.12rem rgba(var(--terCy), 0.25),
|
||||
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.5rem rgba(var(--priCy), 0.12)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-tip {
|
||||
color: rgba(var(--priLm), 1);
|
||||
border-color: rgba(var(--priLm), 1);
|
||||
background-color: rgba(var(--terLm), 1);
|
||||
box-shadow:
|
||||
0.1rem 0.1rem 0.12rem rgba(var(--terLm), 0.25),
|
||||
0.12rem 0.12rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||
0.25rem 0.25rem 0.25rem rgba(var(--terLm), 0.12)
|
||||
;
|
||||
|
||||
&:hover {
|
||||
text-shadow:
|
||||
0.1rem 0.1rem 0.1rem rgba(0, 0, 0, 0.25),
|
||||
0 0 1rem rgba(var(--priLm), 1)
|
||||
;
|
||||
box-shadow:
|
||||
0.12rem 0.12rem 0.5rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.5rem rgba(var(--priLm), 0.12)
|
||||
;
|
||||
}
|
||||
|
||||
&:active {
|
||||
border: 0.18rem solid rgba(var(--priLm), 1);
|
||||
text-shadow:
|
||||
-0.1rem -0.1rem 0.25rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.12rem rgba(var(--priLm), 1)
|
||||
;
|
||||
box-shadow:
|
||||
-0.1rem -0.1rem 0.12rem rgba(var(--terLm), 0.25),
|
||||
-0.1rem -0.1rem 0.12rem rgba(0, 0, 0, 0.25),
|
||||
0 0 0.5rem rgba(var(--priLm), 0.12)
|
||||
;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
672
src/static_src/scss/_card-deck.scss
Normal file
672
src/static_src/scss/_card-deck.scss
Normal file
@@ -0,0 +1,672 @@
|
||||
// ─── Card deck primitives — fan cards + sig-select overlay ─────────────────────
|
||||
//
|
||||
// Shared card display classes (.fan-card, .fan-card-corner, .fan-card-face, .fan-nav)
|
||||
// extracted from _game-kit.scss; sig-select overlay extracted from _room.scss.
|
||||
|
||||
// ── Tarot fan modal ──────────────────────────────────────────────────────────
|
||||
|
||||
#id_tarot_fan_dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
overflow: hidden;
|
||||
|
||||
&::backdrop { display: none; } // Dialog IS the backdrop
|
||||
}
|
||||
|
||||
.tarot-fan-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
perspective: 900px;
|
||||
|
||||
button {
|
||||
box-shadow: none;
|
||||
|
||||
&:hover, &.active {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tarot-fan {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
.fan-card {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 220px;
|
||||
height: 340px;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(var(--priUser), 1);
|
||||
border: 0.1rem solid rgba(var(--secUser), 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||
transform-style: preserve-3d;
|
||||
|
||||
&--active {
|
||||
border-color: rgba(var(--secUser), 1);
|
||||
box-shadow: 0 0 2rem rgba(var(--secUser), 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.fan-card-corner {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
line-height: 1;
|
||||
color: rgba(var(--secUser), 0.75);
|
||||
|
||||
&--tl { top: 0.4rem; left: 0.4rem; }
|
||||
&--br { bottom: 0.4rem; right: 0.4rem; transform: rotate(180deg); }
|
||||
|
||||
.fan-corner-rank {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
padding: 0.18rem 0;
|
||||
}
|
||||
i { font-size: 1.5rem; }
|
||||
}
|
||||
|
||||
.fan-card-face {
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
.fan-card-number { font-size: 0.65rem; }
|
||||
.fan-card-name-group { font-size: 0.65rem; margin: 0; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(var(--secUser), 1); }
|
||||
.fan-card-name { font-size: 0.95rem; font-weight: bold; margin: 0; color: rgba(var(--terUser), 1); }
|
||||
.fan-card-arcana { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; color: rgba(var(--secUser), 1); }
|
||||
.fan-card-correspondence { font-size: 0.6rem; font-style: italic; color: rgba(var(--secUser), 0.5); }
|
||||
}
|
||||
|
||||
.fan-nav {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(var(--secUser), 0.6);
|
||||
cursor: pointer;
|
||||
padding: 1rem;
|
||||
transition: color 0.15s;
|
||||
pointer-events: auto;
|
||||
|
||||
&:hover { color: rgba(var(--secUser), 1); }
|
||||
// Suppress browser focus ring on mouse/touch clicks; retain it for keyboard nav
|
||||
&:focus:not(:focus-visible) { outline: none; box-shadow: none; }
|
||||
&--prev { left: 1rem; }
|
||||
&--next { right: 1rem; }
|
||||
}
|
||||
|
||||
// ─── Sig Select overlay (SIG_SELECT phase) ────────────────────────────────────
|
||||
//
|
||||
// Two overlays (levity / gravity) run in parallel, one per polarity group.
|
||||
// Layout mirrors the gatekeeper: dark Gaussian backdrop + centred modal.
|
||||
// Inside the modal: upper stage (card preview) + lower mini card grid (no scroll).
|
||||
|
||||
html:has(.sig-backdrop) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sig-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(5px);
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sig-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
z-index: 120;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sig-modal {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%; // respects overlay padding-right set by JS
|
||||
max-width: 420px;
|
||||
max-height: 100%; // respects overlay padding-bottom set by JS
|
||||
}
|
||||
|
||||
// ─── Stage ────────────────────────────────────────────────────────────────────
|
||||
// flex: 1 — fills all space above the card grid; no background (backdrop blur).
|
||||
// Row layout: preview card bottom-left, stat block fills the right.
|
||||
// Card width is set by sizeSigCard() in room.js (smaller of 40% stage width or
|
||||
// 80% stage height × 5/8) via --sig-card-w CSS variable — libsass can't handle
|
||||
// container query units inside min().
|
||||
|
||||
.sig-stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-end;
|
||||
padding-left: 1.5rem;
|
||||
gap: 0.75rem;
|
||||
|
||||
// Preview card — width driven by JS via --sig-card-w; aspect-ratio derives height.
|
||||
.sig-stage-card {
|
||||
flex-shrink: 0;
|
||||
width: var(--sig-card-w, 120px);
|
||||
height: auto;
|
||||
aspect-ratio: 5 / 8;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(var(--priUser), 1);
|
||||
border: 0.15rem solid rgba(var(--secUser), 0.6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
padding: 0.25rem;
|
||||
overflow: hidden;
|
||||
|
||||
// game-kit sets .fan-card-corner { position: absolute; top/left offsets }
|
||||
// so these just need display/font overrides; the corners land at the card edges.
|
||||
// All font-sizes scale with --sig-card-w (ratio = original-rem × 16 / 120).
|
||||
.fan-card-corner--tl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
line-height: 1.1;
|
||||
gap: 0.1rem;
|
||||
|
||||
.fan-corner-rank { font-size: calc(var(--sig-card-w, 120px) * 0.133); font-weight: 700; }
|
||||
i { font-size: calc(var(--sig-card-w, 120px) * 0.1); }
|
||||
}
|
||||
|
||||
.fan-card-corner--br {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
line-height: 1.1;
|
||||
gap: 0.1rem;
|
||||
|
||||
.fan-corner-rank { font-size: calc(var(--sig-card-w, 120px) * 0.12); font-weight: 700; }
|
||||
i { font-size: calc(var(--sig-card-w, 120px) * 0.1); }
|
||||
}
|
||||
|
||||
.fan-card-face {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 0.25rem 0.15rem;
|
||||
gap: 0.2rem;
|
||||
|
||||
.fan-card-name-group { font-size: calc(var(--sig-card-w, 120px) * 0.073); opacity: 0.6; }
|
||||
.sig-qualifier-above,
|
||||
.sig-qualifier-below { font-size: calc(var(--sig-card-w, 120px) * 0.093); font-weight: 600; }
|
||||
.fan-card-name { font-size: calc(var(--sig-card-w, 120px) * 0.093); font-weight: 600; }
|
||||
.fan-card-arcana { font-size: calc(var(--sig-card-w, 120px) * 0.067); text-transform: uppercase; letter-spacing: 0.06em; opacity: 0.5; }
|
||||
.fan-card-correspondence{ display: none; } // Minchiate equivalence shown in game-kit only
|
||||
}
|
||||
}
|
||||
|
||||
// Stat block — same dimensions as the preview card (width × 5:8 aspect).
|
||||
// flex: 0 0 auto so it doesn't stretch to fill the stage; the rest of the
|
||||
// stage row is simply empty, giving the card room to breathe.
|
||||
.sig-stat-block {
|
||||
flex: 0 0 auto;
|
||||
width: var(--sig-card-w, 120px);
|
||||
height: calc(var(--sig-card-w, 120px) * 8 / 5);
|
||||
align-self: flex-end;
|
||||
background: rgba(var(--priUser), 0.5);
|
||||
border-radius: 0.4rem;
|
||||
border: 0.1rem solid rgba(var(--terUser), 0.15);
|
||||
display: none;
|
||||
position: relative;
|
||||
|
||||
|
||||
.sig-flip-btn {
|
||||
position: absolute;
|
||||
top: -1rem;
|
||||
right: -1rem;
|
||||
margin: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.sig-caution-btn {
|
||||
position: absolute;
|
||||
top: 1.25rem;
|
||||
right: -1rem;
|
||||
margin: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
// Caution tooltip — covers the entire stat block (inset: 0), z-index above buttons.
|
||||
.sig-caution-tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background-color: rgba(var(--tooltip-bg), 0.6);
|
||||
backdrop-filter: blur(6px);
|
||||
border-radius: 0.4rem;
|
||||
border: 0.1rem solid rgba(var(--priYl), 0.35);
|
||||
padding: 0.75rem;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sig-caution-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.sig-caution-title {
|
||||
font-size: calc(var(--sig-card-w, 120px) * 0.093);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: rgba(var(--priYl), 1);
|
||||
}
|
||||
|
||||
.sig-caution-type {
|
||||
font-size: calc(var(--sig-card-w, 120px) * 0.058);
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sig-caution-shoptalk {
|
||||
font-size: calc(var(--sig-card-w, 120px) * 0.063);
|
||||
opacity: 0.55;
|
||||
margin: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.sig-caution-effect {
|
||||
flex: 1;
|
||||
font-size: calc(var(--sig-card-w, 120px) * 0.075);
|
||||
margin: 0;
|
||||
line-height: 1.55;
|
||||
|
||||
.card-ref {
|
||||
color: rgba(var(--terUser), 1);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.sig-caution-index {
|
||||
font-size: calc(var(--sig-card-w, 120px) * 0.063);
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
// Nav arrows portaled out of tooltip — sit at bottom corners above tooltip (z-70)
|
||||
.sig-caution-prev,
|
||||
.sig-caution-next {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: -1rem;
|
||||
margin: 0;
|
||||
z-index: 70;
|
||||
}
|
||||
.sig-caution-prev { left: -1rem; }
|
||||
.sig-caution-next { right: -1rem; }
|
||||
|
||||
.stat-face {
|
||||
display: none;
|
||||
padding: calc(var(--sig-card-w, 120px) * 0.37) calc(var(--sig-card-w, 120px) * 0.1) calc(var(--sig-card-w, 120px) * 0.08);
|
||||
|
||||
&--upright { display: block; }
|
||||
}
|
||||
|
||||
&.is-reversed {
|
||||
.stat-face--upright { display: none; }
|
||||
.stat-face--reversed { display: block; }
|
||||
}
|
||||
|
||||
.stat-face-label {
|
||||
font-size: calc(var(--sig-card-w, 120px) * 0.063);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.09em;
|
||||
opacity: 0.4;
|
||||
margin: 0 0 calc(var(--sig-card-w, 120px) * 0.07);
|
||||
}
|
||||
|
||||
.stat-keywords {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
li {
|
||||
font-size: calc(var(--sig-card-w, 120px) * 0.083);
|
||||
padding: calc(var(--sig-card-w, 120px) * 0.042) 0;
|
||||
opacity: 0.85;
|
||||
border-bottom: 0.05rem solid rgba(var(--terUser), 0.12);
|
||||
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.sig-stage--frozen .sig-stat-block { display: block; }
|
||||
&.sig-caution-open .sig-stat-block {
|
||||
.sig-caution-tooltip { display: flex; }
|
||||
.sig-caution-prev, .sig-caution-next { display: inline-flex; }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Mini card grid ───────────────────────────────────────────────────────────
|
||||
// flex: 0 0 auto — shrinks to card content; no background (backdrop blur).
|
||||
// align-content: start prevents CSS grid from distributing extra height between rows.
|
||||
|
||||
.sig-deck-grid {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
align-content: start;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
margin: 0 1rem 5rem 4rem;
|
||||
}
|
||||
|
||||
.sig-card {
|
||||
aspect-ratio: 5 / 8;
|
||||
border-radius: 0.4rem;
|
||||
background: rgba(var(--priUser), 0.97);
|
||||
border: 1px solid rgba(var(--secUser), 0.3);
|
||||
position: relative;
|
||||
cursor: grab;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
overflow: hidden;
|
||||
|
||||
// game-kit sets .fan-card-corner { position:absolute; top:0.4rem; left:0.4rem }
|
||||
// Override: center the element within the card instead.
|
||||
.fan-card-corner--tl {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
gap: 0; // game-kit has gap:0.15rem — too large at 0.5rem font-size
|
||||
|
||||
.fan-corner-rank { font-size: 1rem; font-weight: 700; }
|
||||
i { font-size: 0.75rem; }
|
||||
}
|
||||
|
||||
// OK / NVM overlay — appears on click (focused) or own reservation
|
||||
.sig-card-actions {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
background: rgba(var(--priUser), 0.92);
|
||||
border-radius: inherit;
|
||||
|
||||
.sig-nvm-btn { display: none; }
|
||||
}
|
||||
|
||||
&.sig-focused .sig-card-actions { display: flex; }
|
||||
&.sig-reserved--own .sig-card-actions {
|
||||
display: flex;
|
||||
.sig-ok-btn { display: none; }
|
||||
.sig-nvm-btn { display: flex; }
|
||||
}
|
||||
|
||||
// Cursor strip — hangs below the card bottom edge; overflow: visible allows this.
|
||||
.sig-card-cursors {
|
||||
position: absolute;
|
||||
bottom: -0.6rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
// Rise above DOM-order siblings when a peer's cursor is active on this card.
|
||||
// Without this, later cards in the grid paint over the overflowing cursor icons.
|
||||
&:has(.sig-cursor.active) { z-index: 5; }
|
||||
|
||||
&:hover:not([data-reserved-by]) {
|
||||
border-color: rgba(var(--secUser), 0.8);
|
||||
box-shadow: 0 0 4px rgba(var(--secUser), 0.25);
|
||||
}
|
||||
|
||||
&.sig-reserved {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
// Role-coloured reservation glow — border/shadow matches the reserving gamer's role.
|
||||
// data-reserved-by is set by applyReservation() in sig-select.js.
|
||||
// Own reservation also shows role colour (same as peers see), not a separate style.
|
||||
&.sig-reserved {
|
||||
&[data-reserved-by="PC"] { border-color: rgba(var(--priRd), 1); box-shadow: 0 0 0 2px rgba(var(--priRd), 1); }
|
||||
&[data-reserved-by="NC"] { border-color: rgba(var(--priYl), 1); box-shadow: 0 0 0 2px rgba(var(--priYl), 1); }
|
||||
&[data-reserved-by="EC"] { border-color: rgba(var(--priGn), 1); box-shadow: 0 0 0 2px rgba(var(--priGn), 1); }
|
||||
&[data-reserved-by="SC"] { border-color: rgba(var(--priCy), 1); box-shadow: 0 0 0 2px rgba(var(--priCy), 1); }
|
||||
&[data-reserved-by="AC"] { border-color: rgba(var(--priId), 1); box-shadow: 0 0 0 2px rgba(var(--priId), 1); }
|
||||
&[data-reserved-by="BC"] { border-color: rgba(var(--priFs), 1); box-shadow: 0 0 0 2px rgba(var(--priFs), 1); }
|
||||
}
|
||||
|
||||
&.sig-reserved--own {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Cursor anchors ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Three tiny dots along the bottom of each mini card, one per role in the group.
|
||||
// Inactive: invisible. Active (another gamer is hovering): role-coloured dot.
|
||||
// Position order is fixed per polarity (POLARITY_ROLES in sig-select.js):
|
||||
// levity (PC / NC / SC) → left / mid / right
|
||||
// gravity (BC / EC / AC) → left / mid / right
|
||||
|
||||
// In-card cursor elements — invisible anchors only.
|
||||
// Visible icons are portaled to document root by applyHover() in sig-select.js.
|
||||
.sig-cursor {
|
||||
display: block;
|
||||
font-size: 0; // zero-size: no layout impact, just carries .active class
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// ─── Floating cursor portal ───────────────────────────────────────────────────
|
||||
//
|
||||
// sig-select.js creates these <i> elements inside #id_sig_cursor_portal, a
|
||||
// position:fixed root-level container, so they escape all overflow/clip contexts.
|
||||
// Positioned via getBoundingClientRect() on the card element.
|
||||
|
||||
#id_sig_cursor_portal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 200; // above sig-overlay (120), below tray (310)
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.sig-cursor-float {
|
||||
position: absolute;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
transform: translateX(-50%); // centre on the x coordinate from JS
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Role-specific colour + outline shadow + ninUser glow
|
||||
.sig-cursor-float[data-role="PC"] {
|
||||
color: rgba(var(--priRd), 1);
|
||||
text-shadow: 2px 0 0 rgba(var(--priOr),1), -2px 0 0 rgba(var(--priOr),1),
|
||||
0 2px 0 rgba(var(--priOr),1), 0 -2px 0 rgba(var(--priOr),1),
|
||||
0 0 6px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.sig-cursor-float[data-role="NC"] {
|
||||
color: rgba(var(--priYl), 1);
|
||||
text-shadow: 2px 0 0 rgba(var(--priLm),1), -2px 0 0 rgba(var(--priLm),1),
|
||||
0 2px 0 rgba(var(--priLm),1), 0 -2px 0 rgba(var(--priLm),1),
|
||||
0 0 6px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.sig-cursor-float[data-role="EC"] {
|
||||
color: rgba(var(--priGn), 1);
|
||||
text-shadow: 2px 0 0 rgba(var(--priTk),1), -2px 0 0 rgba(var(--priTk),1),
|
||||
0 2px 0 rgba(var(--priTk),1), 0 -2px 0 rgba(var(--priTk),1),
|
||||
0 0 6px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.sig-cursor-float[data-role="SC"] {
|
||||
color: rgba(var(--priCy), 1);
|
||||
text-shadow: 2px 0 0 rgba(var(--priBl),1), -2px 0 0 rgba(var(--priBl),1),
|
||||
0 2px 0 rgba(var(--priBl),1), 0 -2px 0 rgba(var(--priBl),1),
|
||||
0 0 6px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.sig-cursor-float[data-role="AC"] {
|
||||
color: rgba(var(--priId), 1);
|
||||
text-shadow: 2px 0 0 rgba(var(--priVt),1), -2px 0 0 rgba(var(--priVt),1),
|
||||
0 2px 0 rgba(var(--priVt),1), 0 -2px 0 rgba(var(--priVt),1),
|
||||
0 0 6px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.sig-cursor-float[data-role="BC"] {
|
||||
color: rgba(var(--priFs), 1);
|
||||
text-shadow: 2px 0 0 rgba(var(--priMe),1), -2px 0 0 rgba(var(--priMe),1),
|
||||
0 2px 0 rgba(var(--priMe),1), 0 -2px 0 rgba(var(--priMe),1),
|
||||
0 0 6px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
// ─── Polarity theming — card colour inversion ────────────────────────────────
|
||||
//
|
||||
// Gravity (Graven): --priUser bg / --secUser text — standard dark palette.
|
||||
// Levity (Leavened): --secUser bg / --priUser text — inverted, lighter feel.
|
||||
// Both mini-cards and the stage preview card follow the same rule.
|
||||
|
||||
.sig-overlay[data-polarity="levity"] {
|
||||
// Mini card: inverted palette. game-kit sets explicit colours on .fan-card-name
|
||||
// and .fan-card-corner that out-specifc the parent color, so re-target them here.
|
||||
.sig-card {
|
||||
background: rgba(var(--secUser), 0.97);
|
||||
border-color: rgba(var(--priUser), 0.3);
|
||||
color: rgba(var(--priUser), 1);
|
||||
.fan-card-corner { color: rgba(var(--priUser), 0.75); }
|
||||
.fan-card-name { color: rgba(var(--quiUser), 1); }
|
||||
// OK / NVM overlay — must match the inverted card background
|
||||
.sig-card-actions { background: rgba(var(--secUser), 0.92); }
|
||||
}
|
||||
// Stage preview card: same inversion + title colour.
|
||||
// .fan-card-name-group and .fan-card-arcana have explicit color in the base
|
||||
// .fan-card-face rule (specificity 0,2,0) — must re-target them here (0,3,0).
|
||||
// Opacity dim is still applied by the nested sig-stage-card rule.
|
||||
.sig-stage-card {
|
||||
background: rgba(var(--secUser), 1);
|
||||
border-color: rgba(var(--priUser), 0.6);
|
||||
color: rgba(var(--priUser), 1);
|
||||
.fan-card-corner { color: rgba(var(--priUser), 0.75); }
|
||||
.fan-card-name-group{ color: rgba(var(--priUser), 1); }
|
||||
.fan-card-name { color: rgba(var(--quiUser), 1); }
|
||||
.fan-card-arcana { color: rgba(var(--priUser), 1); }
|
||||
}
|
||||
// Polarity qualifier: same colour as the card title in this context
|
||||
.sig-qualifier-above,
|
||||
.sig-qualifier-below { color: rgba(var(--quiUser), 1); }
|
||||
// card-ref spans inside the caution tooltip — must match the base rule's
|
||||
// .sig-stat-block .sig-caution-effect .card-ref specificity (0,3,0) to win.
|
||||
.sig-caution-effect .card-ref { color: rgba(var(--quiUser), 1); }
|
||||
// Cursor colours live in .sig-cursor-float[data-role] rules (portal elements)
|
||||
}
|
||||
.sig-overlay[data-polarity="gravity"] {
|
||||
// Stat block: invert priUser/secUser so gravity gets the same stark contrast as leavened cards
|
||||
.sig-stat-block {
|
||||
background: rgba(var(--secUser), 0.75);
|
||||
color: rgba(var(--priUser), 1);
|
||||
border-color: rgba(var(--priUser), 0.15);
|
||||
}
|
||||
// Caution tooltip: --tooltip-bg is black so priUser text (dark) would be invisible —
|
||||
// override to secUser (light) so body text reads against the dark backdrop.
|
||||
.sig-caution-tooltip { color: rgba(var(--secUser), 1); }
|
||||
// Polarity qualifier: terUser for gravity (quiUser is levity's equivalent)
|
||||
.sig-qualifier-above,
|
||||
.sig-qualifier-below { color: rgba(var(--terUser), 1); }
|
||||
// Cursor colours live in .sig-cursor-float[data-role] rules (portal elements)
|
||||
}
|
||||
|
||||
// ─── Sig select: landscape overrides ─────────────────────────────────────────
|
||||
// Landscape base: 9×2 grid of 3rem cards. At ≥992px (wide enough for 18 cards
|
||||
// at 3rem + 4rem left + ~4rem right): collapse to a single 18×1 row so the
|
||||
// stage preview gets maximum vertical real-estate.
|
||||
// padding-left clears the fixed left navbar (JS sets right/bottom but not left).
|
||||
// Grid margins reset to 0 — overlay padding handles all edge clearance.
|
||||
|
||||
@media (orientation: landscape) {
|
||||
.sig-modal {
|
||||
max-width: none;
|
||||
flex-direction: row; // grid to the right, stage + card preview to the left
|
||||
margin-left: 4rem;
|
||||
margin-right: 3rem;
|
||||
}
|
||||
.sig-stage {
|
||||
min-width: 0; // allow shrinking in row layout; align-items:flex-end already set
|
||||
}
|
||||
.sig-deck-grid {
|
||||
grid-template-columns: repeat(6, 2.5rem);
|
||||
margin: 0;
|
||||
align-self: flex-end; // sit at the bottom of the modal row
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (min-width: 900px) {
|
||||
// Wide landscape: revert to stacked layout (stage top, 18-card row grid bottom).
|
||||
.sig-modal {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.sig-stage {
|
||||
min-width: auto;
|
||||
align-self: stretch; // fill full modal width so JS sizeSigCard() gets correct stageWidth
|
||||
margin-left: 3rem;
|
||||
}
|
||||
.sig-deck-grid {
|
||||
grid-template-columns: repeat(18, 3rem);
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (min-width: 1800px) {
|
||||
// Sig overlay: clear doubled sidebars (8rem each instead of 4rem/6rem)
|
||||
.sig-overlay { padding-left: 8rem; padding-right: 8rem; }
|
||||
.sig-stage {
|
||||
align-self: stretch; // fill full modal width so JS sizeSigCard() gets correct stageWidth
|
||||
margin-left: 3rem;
|
||||
}
|
||||
.sig-deck-grid {
|
||||
grid-template-columns: repeat(18, 5rem);
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
// Room menu: base right: 0.5rem (same-specificity ID rule) overrides _applets.scss
|
||||
// XL block because _card-deck.scss is imported after _applets.scss. Re-declare here to win the cascade.
|
||||
#id_room_menu { right: 2.5rem; }
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ body.page-dashboard {
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (max-width: 1440px) {
|
||||
@media (orientation: landscape) {
|
||||
// Reset the 666px min-width so #id_dash_content shrinks to fit within the
|
||||
// sidebar-bounded container rather than overflowing into the footer sidebar.
|
||||
#id_dash_content {
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
bottom: 0.5rem;
|
||||
right: 0.5rem;
|
||||
|
||||
@media (orientation: landscape) and (max-width: 1440px) {
|
||||
right: calc(4rem + 0.5rem);
|
||||
bottom: 0.75rem;
|
||||
@media (orientation: landscape) {
|
||||
right: 1rem;
|
||||
bottom: 0.5rem;
|
||||
top: auto;
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (min-width: 1800px) {
|
||||
right: 2.5rem; // centre in doubled 8rem sidebar
|
||||
}
|
||||
|
||||
z-index: 318;
|
||||
font-size: 1.75rem;
|
||||
cursor: pointer;
|
||||
@@ -45,7 +49,7 @@
|
||||
z-index: 316;
|
||||
overflow: hidden;
|
||||
|
||||
@media (orientation: landscape) and (max-width: 1440px) {
|
||||
@media (orientation: landscape) {
|
||||
$sidebar-w: 4rem;
|
||||
// left: $sidebar-w;
|
||||
right: $sidebar-w;
|
||||
@@ -142,6 +146,13 @@
|
||||
|
||||
// ── Game Kit page ────────────────────────────────────────────────────────────
|
||||
|
||||
#id_game_kit_applets_container {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#id_game_kit_applets_container section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -201,118 +212,3 @@
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
// ── Tarot fan modal ──────────────────────────────────────────────────────────
|
||||
|
||||
#id_tarot_fan_dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
overflow: hidden;
|
||||
|
||||
&::backdrop { display: none; } // Dialog IS the backdrop
|
||||
}
|
||||
|
||||
.tarot-fan-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
perspective: 900px;
|
||||
|
||||
button {
|
||||
box-shadow: none;
|
||||
|
||||
&:hover, &.active {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tarot-fan {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
.fan-card {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 220px;
|
||||
height: 340px;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(var(--priUser), 1);
|
||||
border: 0.1rem solid rgba(var(--secUser), 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||
transform-style: preserve-3d;
|
||||
|
||||
&--active {
|
||||
border-color: rgba(var(--secUser), 1);
|
||||
box-shadow: 0 0 2rem rgba(var(--secUser), 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.fan-card-corner {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
line-height: 1;
|
||||
color: rgba(var(--secUser), 0.75);
|
||||
|
||||
&--tl { top: 0.4rem; left: 0.4rem; }
|
||||
&--br { bottom: 0.4rem; right: 0.4rem; transform: rotate(180deg); }
|
||||
|
||||
.fan-corner-rank {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
padding: 0.18rem 0;
|
||||
}
|
||||
i { font-size: 1.5rem; }
|
||||
}
|
||||
|
||||
.fan-card-face {
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
.fan-card-number { font-size: 0.65rem; }
|
||||
.fan-card-name-group { font-size: 0.65rem; margin: 0; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(var(--secUser), 1); }
|
||||
.fan-card-name { font-size: 0.95rem; font-weight: bold; margin: 0; color: rgba(var(--terUser), 1); }
|
||||
.fan-card-arcana { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.1em; color: rgba(var(--secUser), 1); }
|
||||
.fan-card-correspondence { font-size: 0.6rem; font-style: italic; color: rgba(var(--secUser), 0.5); }
|
||||
}
|
||||
|
||||
.fan-nav {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
font-size: 3rem;
|
||||
line-height: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(var(--secUser), 0.6);
|
||||
cursor: pointer;
|
||||
padding: 1rem;
|
||||
transition: color 0.15s;
|
||||
pointer-events: auto;
|
||||
|
||||
&:hover { color: rgba(var(--secUser), 1); }
|
||||
// Suppress browser focus ring on mouse/touch clicks; retain it for keyboard nav
|
||||
&:focus:not(:focus-visible) { outline: none; box-shadow: none; }
|
||||
&--prev { left: 1rem; }
|
||||
&--next { right: 1rem; }
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ body.page-gameboard {
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (max-width: 1440px) {
|
||||
@media (orientation: landscape) {
|
||||
// Restore clip in landscape — overrides the >738px overflow:visible above,
|
||||
// preventing the gameboard applets from bleeding into the footer sidebar.
|
||||
body.page-gameboard .container {
|
||||
|
||||
@@ -40,6 +40,27 @@ html:has(.gate-backdrop) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Aperture fill — solid --duoUser layer that covers the game table (.room-page).
|
||||
// Uses position:absolute so it's clipped to .room-page bounds (overflow:hidden),
|
||||
// naturally staying below the h2 title + navbar/footer in both orientations.
|
||||
// Sits at z-90: below blur backdrops (z-100) which render on top via backdrop-filter.
|
||||
// Fades in/out via opacity transition when a backdrop class is present.
|
||||
#id_aperture_fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(var(--duoUser), 1);
|
||||
z-index: 90;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
html:has(.gate-backdrop) #id_aperture_fill,
|
||||
html:has(.sig-backdrop) #id_aperture_fill,
|
||||
html:has(.role-select-backdrop) #id_aperture_fill {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.gate-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -60,21 +81,67 @@ html:has(.gate-backdrop) {
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.launch-game-btn {
|
||||
margin-top: 1rem;
|
||||
margin-top: 5rem;
|
||||
}
|
||||
|
||||
.gate-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
min-width: 26rem;
|
||||
pointer-events: auto;
|
||||
padding: 2rem;
|
||||
border: 0.1rem solid rgba(var(--terUser), 0.5);
|
||||
border-radius: 1rem;
|
||||
background-color: rgba(var(--priUser), 1);
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
|
||||
.gate-title-panel {
|
||||
border: 0.1rem solid rgba(var(--terUser), 0.25);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(var(--priUser), 1);
|
||||
}
|
||||
|
||||
.gate-top-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gate-main-panel {
|
||||
flex: 3;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
border: 0.1rem solid rgba(var(--terUser), 0.25);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(var(--priUser), 1);
|
||||
}
|
||||
|
||||
.gate-roles-panel {
|
||||
flex: 1;
|
||||
min-width: 5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0.1rem solid rgba(var(--terUser), 0.25);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(var(--priUser), 1);
|
||||
|
||||
.launch-game-btn { margin-top: 0; }
|
||||
}
|
||||
|
||||
.gate-invite-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
border: 0.1rem solid rgba(var(--terUser), 0.25);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(var(--priUser), 1);
|
||||
}
|
||||
|
||||
.gate-header {
|
||||
text-align: center;
|
||||
@@ -89,7 +156,7 @@ html:has(.gate-backdrop) {
|
||||
text-transform: uppercase;
|
||||
text-shadow:
|
||||
1px 1px 0 rgba(255, 255, 255, 0.125), // highlight (up-left)
|
||||
-0.125rem -0.125rem 0 rgba(0, 0, 0, 0.8) // shadow (down-right)
|
||||
var(--title-shadow-offset) var(--title-shadow-offset) 0 rgba(0, 0, 0, 0.8) // shadow (down-right)
|
||||
;
|
||||
|
||||
span {
|
||||
@@ -106,8 +173,6 @@ html:has(.gate-backdrop) {
|
||||
font-size: 0.75em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.15em;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.status-dots {
|
||||
display: inline-flex;
|
||||
span {
|
||||
@@ -243,9 +308,6 @@ html:has(.gate-backdrop) {
|
||||
}
|
||||
}
|
||||
|
||||
.form-container {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Narrow viewport — scale down, 2×3 slot grid (portrait mobile + narrow desktop)
|
||||
@@ -260,7 +322,6 @@ html:has(.gate-backdrop) {
|
||||
|
||||
.gate-header {
|
||||
h1 { font-size: 1.5rem; }
|
||||
.gate-status-wrap { margin-bottom: 0.5rem; }
|
||||
}
|
||||
|
||||
.token-slot { min-width: 150px; }
|
||||
@@ -320,7 +381,7 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut
|
||||
|
||||
.position-strip {
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
top: 1rem;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 130;
|
||||
@@ -739,143 +800,43 @@ $card-h: 60px;
|
||||
}
|
||||
|
||||
// Landscape mobile — aggressively scale down to fit short viewport
|
||||
@media (orientation: landscape) and (max-width: 1440px) {
|
||||
// Sink navbar below gate/role-select overlays when a modal is open.
|
||||
// Landscape navbar z-index is 300 (_base.scss); gate-backdrop/overlay are
|
||||
// 100/120, so the sidebar bleeds over the modal without this override.
|
||||
@media (orientation: landscape) {
|
||||
// Sink navbar + footer sidebar below any modal backdrop when open.
|
||||
// Landscape navbar and footer sidebar are both z-index:100 (_base.scss).
|
||||
// Gate/role-select/sig backdrops are also z-index:100 — DOM paint-order ties
|
||||
// let the footer (later in DOM) bleed through. Drop both to 50.
|
||||
html:has(.gate-backdrop) body .container .navbar,
|
||||
html:has(.role-select-backdrop) body .container .navbar {
|
||||
html:has(.role-select-backdrop) body .container .navbar,
|
||||
html:has(.sig-backdrop) body .container .navbar {
|
||||
z-index: 50;
|
||||
}
|
||||
html:has(.gate-backdrop) body #id_footer,
|
||||
html:has(.role-select-backdrop) body #id_footer,
|
||||
html:has(.sig-backdrop) body #id_footer {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
// Reflow position strip into a vertical column along the left edge,
|
||||
// reversed so 6 is at top, 1 at bottom, below the GAMEROOM title.
|
||||
// Position strip: horizontal row across the top, slots 1-6 in order.
|
||||
// Offset from both sidebars (5rem each) and centred with gap.
|
||||
.position-strip {
|
||||
flex-direction: column-reverse;
|
||||
top: 3rem;
|
||||
left: 0.5rem;
|
||||
right: auto;
|
||||
flex-direction: row;
|
||||
top: 2.5rem;
|
||||
left: 5rem;
|
||||
right: 5rem;
|
||||
justify-content: center;
|
||||
gap: round($gate-gap * 0.4);
|
||||
}
|
||||
|
||||
// Shallow landscape (phones): wrap into two columns — left: 6,5,4 / right: 3,2,1
|
||||
// Columns grow rightward (wrap, not wrap-reverse) so overflow: hidden doesn't clip.
|
||||
// order: -1 on slots 4–6 pulls them to the front of the flex sequence; combined
|
||||
// with column-reverse they land in the left column reading 6,5,4 top-to-bottom.
|
||||
// Small landscape (phones ≤550px tall): strip stays horizontal — no two-column
|
||||
// trick needed now that the h2 is in the gutter. Just clear any order overrides.
|
||||
@media (max-height: 550px) {
|
||||
.position-strip {
|
||||
flex-wrap: wrap;
|
||||
// cap height to exactly 3 circles so the 4th wraps to a new column
|
||||
max-height: #{3 * round($gate-node * 0.75) + 2 * round($gate-gap * 0.4)};
|
||||
|
||||
.gate-slot[data-slot="4"],
|
||||
.gate-slot[data-slot="5"],
|
||||
.gate-slot[data-slot="6"] { order: -1; }
|
||||
}
|
||||
}
|
||||
|
||||
.gate-modal {
|
||||
padding: 0.6rem 1.25rem;
|
||||
|
||||
.gate-header {
|
||||
h1 { font-size: 1rem; margin: 0 0 0.25rem; }
|
||||
.gate-status-wrap { font-size: 0.65em; margin-bottom: 0.35rem; }
|
||||
}
|
||||
|
||||
.token-slot {
|
||||
min-width: 130px;
|
||||
|
||||
.token-rails,
|
||||
button.token-rails { padding: 0.4rem 0.35rem; }
|
||||
|
||||
.token-panel {
|
||||
padding: 0.3rem 0.5rem;
|
||||
|
||||
.token-denomination { font-size: 1.1em; }
|
||||
}
|
||||
}
|
||||
|
||||
.form-container {
|
||||
margin-top: 0.75rem;
|
||||
h3 { font-size: 0.85rem; margin: 0.5rem 0; }
|
||||
|
||||
form { gap: 0.35rem; }
|
||||
|
||||
.form-control-lg {
|
||||
--_pad-v: 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.gate-slot { order: 0; }
|
||||
top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ─── Significator deck (SIG_SELECT phase) ──────────────────────────────────
|
||||
|
||||
// When the sig deck is present, switch room-page from centred to column layout
|
||||
.room-page:has(#id_sig_deck) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
gap: 1rem;
|
||||
|
||||
.room-shell {
|
||||
max-height: 50vh;
|
||||
}
|
||||
}
|
||||
|
||||
#id_sig_deck {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
padding: 0.75rem;
|
||||
overflow-y: auto;
|
||||
align-content: flex-start;
|
||||
max-height: 45vh;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(var(--terUser), 0.3) transparent;
|
||||
}
|
||||
|
||||
.sig-card {
|
||||
width: 70px;
|
||||
height: 108px;
|
||||
border-radius: 0.4rem;
|
||||
background: rgba(var(--priUser), 1);
|
||||
border: 0.1rem solid rgba(var(--secUser), 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 0.25rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, border-color 0.15s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(var(--secUser), 1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0 0.5rem rgba(var(--secUser), 0.3);
|
||||
}
|
||||
|
||||
// Bottom corner is redundant at this size
|
||||
.fan-card-corner--br { display: none; }
|
||||
|
||||
// Top corner — override game-kit's 1.5rem defaults with deeper nesting
|
||||
.fan-card-corner--tl {
|
||||
.fan-corner-rank { font-size: 0.65rem; padding: 0; }
|
||||
i { font-size: 0.55rem; }
|
||||
}
|
||||
|
||||
// Face — deeper nesting to beat game-kit specificity
|
||||
.fan-card-face {
|
||||
padding: 0.25rem 0.2rem;
|
||||
gap: 0.1rem;
|
||||
|
||||
.fan-card-name-group { font-size: 0.38rem; }
|
||||
.fan-card-name { font-size: 0.5rem; }
|
||||
.fan-card-arcana { font-size: 0.35rem; }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Seat tray — see _tray.scss ─────────────────────────────────────────────
|
||||
|
||||
@@ -121,24 +121,29 @@ $handle-r: 1rem;
|
||||
&::before { border-color: rgba(var(--quaUser), 1); }
|
||||
}
|
||||
|
||||
// ─── Role card: arc-in animation (portrait) ─────────────────────────────────
|
||||
// ─── Role card: scrawl fade-in ───────────────────────────────────────────────
|
||||
@keyframes tray-role-arc-in {
|
||||
from { opacity: 0; transform: scale(0.3) translate(-40%, -40%); }
|
||||
to { opacity: 1; transform: scale(1) translate(0, 0); }
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
.tray-role-card {
|
||||
background: rgba(var(--quaUser), 0.25);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
padding: 0.2em;
|
||||
font-size: 0.65rem;
|
||||
color: rgba(var(--quaUser), 1);
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
|
||||
&.arc-in {
|
||||
animation: tray-role-arc-in 1.0s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
transform: scale(1.4); // crop SVG's internal margins
|
||||
}
|
||||
|
||||
// Cell stays static; only the scrawl image fades in.
|
||||
&.arc-in img {
|
||||
animation: tray-role-arc-in 1s ease forwards;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,15 +306,7 @@ $handle-r: 1rem;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
// Role card arc-in for landscape
|
||||
@keyframes tray-role-arc-in-landscape {
|
||||
from { opacity: 0; transform: scale(0.3) translate(-40%, 40%); }
|
||||
to { opacity: 1; transform: scale(1) translate(0, 0); }
|
||||
}
|
||||
|
||||
.tray-role-card.arc-in {
|
||||
animation: tray-role-arc-in-landscape 1.0s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
}
|
||||
// Role card: same fade-in in landscape — no override needed.
|
||||
|
||||
@keyframes tray-wobble-landscape {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
@@ -328,3 +325,5 @@ $handle-r: 1rem;
|
||||
80% { transform: translateY(-3px); }
|
||||
}
|
||||
}
|
||||
|
||||
// ≥1800px uses the same landscape tray rules as narrower landscape — no override block needed.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@import 'gameboard';
|
||||
@import 'palette-picker';
|
||||
@import 'room';
|
||||
@import 'card-deck';
|
||||
@import 'tray';
|
||||
@import 'billboard';
|
||||
@import 'game-kit';
|
||||
|
||||
608
src/static_src/tests/SigSelectSpec.js
Normal file
608
src/static_src/tests/SigSelectSpec.js
Normal file
@@ -0,0 +1,608 @@
|
||||
describe("SigSelect", () => {
|
||||
let testDiv, stageCard, card, statBlock;
|
||||
|
||||
function makeFixture({ reservations = '{}', cardCautions = '[]', polarity = 'levity', userRole = 'PC' } = {}) {
|
||||
testDiv = document.createElement("div");
|
||||
testDiv.innerHTML = `
|
||||
<div class="sig-overlay"
|
||||
data-polarity="${polarity}"
|
||||
data-user-role="${userRole}"
|
||||
data-reserve-url="/epic/room/test/sig-reserve"
|
||||
data-reservations="${reservations.replace(/"/g, '"')}">
|
||||
<div class="sig-modal">
|
||||
<div class="sig-stage">
|
||||
<div class="sig-stage-card" style="display:none">
|
||||
<span class="fan-corner-rank"></span>
|
||||
<i class="stage-suit-icon"></i>
|
||||
<p class="fan-card-name-group"></p>
|
||||
<p class="sig-qualifier-above"></p>
|
||||
<h3 class="fan-card-name"></h3>
|
||||
<p class="sig-qualifier-below"></p>
|
||||
<p class="fan-card-arcana"></p>
|
||||
<p class="fan-card-correspondence"></p>
|
||||
</div>
|
||||
<div class="sig-stat-block">
|
||||
<button class="btn btn-reverse sig-flip-btn" type="button">FLIP</button>
|
||||
<button class="btn btn-caution sig-caution-btn" type="button">!!</button>
|
||||
<div class="stat-face stat-face--upright">
|
||||
<p class="stat-face-label">Upright</p>
|
||||
<ul class="stat-keywords" id="id_stat_keywords_upright"></ul>
|
||||
</div>
|
||||
<div class="stat-face stat-face--reversed">
|
||||
<p class="stat-face-label">Reversed</p>
|
||||
<ul class="stat-keywords" id="id_stat_keywords_reversed"></ul>
|
||||
</div>
|
||||
<button class="btn btn-nav-left sig-caution-prev" type="button">◀</button>
|
||||
<button class="btn btn-nav-right sig-caution-next" type="button">▶</button>
|
||||
<div class="sig-caution-tooltip" id="id_sig_caution">
|
||||
<div class="sig-caution-header">
|
||||
<h4 class="sig-caution-title">Caution!</h4>
|
||||
<span class="sig-caution-type">Rival Interaction</span>
|
||||
</div>
|
||||
<p class="sig-caution-shoptalk">[Shoptalk forthcoming]</p>
|
||||
<p class="sig-caution-effect"></p>
|
||||
<span class="sig-caution-index"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sig-deck-grid">
|
||||
<div class="sig-card"
|
||||
data-card-id="42"
|
||||
data-corner-rank="K"
|
||||
data-suit-icon=""
|
||||
data-name-group="Pentacles"
|
||||
data-name-title="King of Pentacles"
|
||||
data-arcana="Minor Arcana"
|
||||
data-correspondence=""
|
||||
data-keywords-upright="action,impulsiveness,ambition"
|
||||
data-keywords-reversed="no direction,disregard for consequences"
|
||||
data-cautions="${cardCautions.replace(/"/g, '"')}">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">K</span>
|
||||
</div>
|
||||
<div class="sig-card-actions">
|
||||
<button class="sig-ok-btn btn btn-confirm">OK</button>
|
||||
<button class="sig-nvm-btn btn btn-cancel">NVM</button>
|
||||
</div>
|
||||
<div class="sig-card-cursors">
|
||||
<span class="sig-cursor sig-cursor--left"></span>
|
||||
<span class="sig-cursor sig-cursor--mid"></span>
|
||||
<span class="sig-cursor sig-cursor--right"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(testDiv);
|
||||
stageCard = testDiv.querySelector(".sig-stage-card");
|
||||
statBlock = testDiv.querySelector(".sig-stat-block");
|
||||
card = testDiv.querySelector(".sig-card");
|
||||
window.fetch = jasmine.createSpy("fetch").and.returnValue(
|
||||
Promise.resolve({ ok: true })
|
||||
);
|
||||
window._roomSocket = { readyState: -1, send: jasmine.createSpy("send") };
|
||||
SigSelect._testInit();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (testDiv) testDiv.remove();
|
||||
delete window._roomSocket;
|
||||
});
|
||||
|
||||
// ── Stage reveal on mouseenter ─────────────────────────────────────── //
|
||||
|
||||
describe("stage preview", () => {
|
||||
beforeEach(() => makeFixture());
|
||||
|
||||
it("shows the stage card on mouseenter", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(stageCard.style.display).toBe("");
|
||||
});
|
||||
|
||||
it("hides the stage card on mouseleave when not frozen", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||
expect(stageCard.style.display).toBe("none");
|
||||
});
|
||||
|
||||
it("does NOT hide the stage card on mouseleave when frozen (reserved)", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
SigSelect._setFrozen(true);
|
||||
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||
expect(stageCard.style.display).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Card focus (click → OK overlay) ───────────────────────────────── //
|
||||
|
||||
describe("card click", () => {
|
||||
beforeEach(() => makeFixture());
|
||||
|
||||
it("adds .sig-focused to the clicked card", () => {
|
||||
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(card.classList.contains("sig-focused")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the stage card after click", () => {
|
||||
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(stageCard.style.display).toBe("");
|
||||
});
|
||||
|
||||
it("does not focus a card reserved by another role", () => {
|
||||
card.dataset.reservedBy = "NC";
|
||||
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(card.classList.contains("sig-focused")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Lock after reservation ─────────────────────────────────────────── //
|
||||
|
||||
describe("lock after reservation", () => {
|
||||
beforeEach(() => makeFixture());
|
||||
|
||||
it("does not focus another card while one is reserved", () => {
|
||||
// Simulate a reservation on some other card (not this one)
|
||||
SigSelect._setReservedCardId("99");
|
||||
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(card.classList.contains("sig-focused")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not call fetch when OK is clicked while a different card is reserved", () => {
|
||||
SigSelect._setReservedCardId("99");
|
||||
var okBtn = card.querySelector(".sig-ok-btn");
|
||||
okBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(window.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows focus again after reservation is cleared", () => {
|
||||
SigSelect._setReservedCardId("99");
|
||||
SigSelect._setReservedCardId(null);
|
||||
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(card.classList.contains("sig-focused")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── WS release clears NVM in a second browser ─────────────────────── //
|
||||
// Simulates the same gamer having two tabs open: tab B must clear its
|
||||
// .sig-reserved--own when tab A presses NVM (WS release event arrives).
|
||||
// The release payload must carry the card_id so the JS can find the element.
|
||||
|
||||
describe("WS release event (second-browser NVM sync)", () => {
|
||||
beforeEach(() => makeFixture({ reservations: '{"42":"PC"}' }));
|
||||
|
||||
it("removes .sig-reserved and .sig-reserved--own on WS release", () => {
|
||||
// Confirm reservation was applied on init
|
||||
expect(card.classList.contains("sig-reserved--own")).toBe(true);
|
||||
expect(card.classList.contains("sig-reserved")).toBe(true);
|
||||
|
||||
// Tab A presses NVM — tab B receives this WS event with the card_id
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "PC", reserved: false },
|
||||
}));
|
||||
|
||||
expect(card.classList.contains("sig-reserved--own")).toBe(false);
|
||||
expect(card.classList.contains("sig-reserved")).toBe(false);
|
||||
});
|
||||
|
||||
it("unfreezes the stage so other cards can be focused after WS release", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "PC", reserved: false },
|
||||
}));
|
||||
|
||||
// Should now be able to click the card body again
|
||||
card.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(card.classList.contains("sig-focused")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Caution tooltip (!!) ──────────────────────────────────────────── //
|
||||
|
||||
describe("caution tooltip", () => {
|
||||
var cautionTooltip, cautionEffect, cautionPrev, cautionNext, cautionBtn;
|
||||
|
||||
beforeEach(() => {
|
||||
makeFixture();
|
||||
cautionTooltip = testDiv.querySelector(".sig-caution-tooltip");
|
||||
cautionEffect = testDiv.querySelector(".sig-caution-effect");
|
||||
cautionPrev = testDiv.querySelector(".sig-caution-prev");
|
||||
cautionNext = testDiv.querySelector(".sig-caution-next");
|
||||
cautionBtn = testDiv.querySelector(".sig-caution-btn");
|
||||
});
|
||||
|
||||
function hover() {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
}
|
||||
|
||||
function openCaution() {
|
||||
hover();
|
||||
cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
}
|
||||
|
||||
it("!! click adds .sig-caution-open to the stage", () => {
|
||||
openCaution();
|
||||
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true);
|
||||
});
|
||||
|
||||
it("FYI click when btn-disabled does not close caution", () => {
|
||||
openCaution();
|
||||
expect(cautionBtn.classList.contains("btn-disabled")).toBe(true);
|
||||
cautionBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows placeholder text when cautions list is empty", () => {
|
||||
card.dataset.cautions = "[]";
|
||||
openCaution();
|
||||
expect(cautionEffect.innerHTML).toContain("pending");
|
||||
});
|
||||
|
||||
it("renders first caution effect HTML including .card-ref spans", () => {
|
||||
card.dataset.cautions = JSON.stringify(['First <span class="card-ref">Card</span> effect.']);
|
||||
openCaution();
|
||||
expect(cautionEffect.querySelector(".card-ref")).not.toBeNull();
|
||||
expect(cautionEffect.querySelector(".card-ref").textContent).toBe("Card");
|
||||
});
|
||||
|
||||
it("with 1 caution both nav arrows are disabled", () => {
|
||||
card.dataset.cautions = JSON.stringify(["Single caution."]);
|
||||
openCaution();
|
||||
expect(cautionPrev.disabled).toBe(true);
|
||||
expect(cautionNext.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("with multiple cautions both nav arrows are always enabled", () => {
|
||||
card.dataset.cautions = JSON.stringify(["C1", "C2", "C3", "C4"]);
|
||||
openCaution();
|
||||
expect(cautionPrev.disabled).toBe(false);
|
||||
expect(cautionNext.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("next click advances to second caution", () => {
|
||||
card.dataset.cautions = JSON.stringify(["First", "Second"]);
|
||||
openCaution();
|
||||
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(cautionEffect.innerHTML).toContain("Second");
|
||||
});
|
||||
|
||||
it("next wraps from last caution back to first", () => {
|
||||
card.dataset.cautions = JSON.stringify(["First", "Last"]);
|
||||
openCaution();
|
||||
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(cautionEffect.innerHTML).toContain("First");
|
||||
});
|
||||
|
||||
it("prev click goes back to first caution", () => {
|
||||
card.dataset.cautions = JSON.stringify(["First", "Second"]);
|
||||
openCaution();
|
||||
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
cautionPrev.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(cautionEffect.innerHTML).toContain("First");
|
||||
});
|
||||
|
||||
it("prev wraps from first caution to last", () => {
|
||||
card.dataset.cautions = JSON.stringify(["First", "Middle", "Last"]);
|
||||
openCaution();
|
||||
cautionPrev.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(cautionEffect.innerHTML).toContain("Last");
|
||||
});
|
||||
|
||||
it("index label shows n / total when multiple cautions", () => {
|
||||
card.dataset.cautions = JSON.stringify(["C1", "C2", "C3"]);
|
||||
openCaution();
|
||||
expect(testDiv.querySelector(".sig-caution-index").textContent).toBe("1 / 3");
|
||||
});
|
||||
|
||||
it("index label is empty when only 1 caution", () => {
|
||||
card.dataset.cautions = JSON.stringify(["Only one."]);
|
||||
openCaution();
|
||||
expect(testDiv.querySelector(".sig-caution-index").textContent).toBe("");
|
||||
});
|
||||
|
||||
it("card mouseleave closes the caution", () => {
|
||||
openCaution();
|
||||
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true);
|
||||
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false);
|
||||
});
|
||||
|
||||
it("opening again resets to first caution", () => {
|
||||
card.dataset.cautions = JSON.stringify(["First", "Second"]);
|
||||
openCaution();
|
||||
cautionNext.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
// Close and reopen
|
||||
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||
openCaution();
|
||||
expect(cautionEffect.innerHTML).toContain("First");
|
||||
});
|
||||
|
||||
it("opening caution adds .btn-disabled and swaps labels to ×", () => {
|
||||
openCaution();
|
||||
var flipBtn = testDiv.querySelector(".sig-flip-btn");
|
||||
expect(flipBtn.classList.contains("btn-disabled")).toBe(true);
|
||||
expect(cautionBtn.classList.contains("btn-disabled")).toBe(true);
|
||||
expect(flipBtn.textContent).toBe("\u00D7");
|
||||
expect(cautionBtn.textContent).toBe("\u00D7");
|
||||
});
|
||||
|
||||
it("closing caution removes .btn-disabled and restores original labels", () => {
|
||||
var flipBtn = testDiv.querySelector(".sig-flip-btn");
|
||||
var origFlip = flipBtn.textContent;
|
||||
var origCaution = cautionBtn.textContent;
|
||||
openCaution();
|
||||
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||
expect(flipBtn.classList.contains("btn-disabled")).toBe(false);
|
||||
expect(cautionBtn.classList.contains("btn-disabled")).toBe(false);
|
||||
expect(flipBtn.textContent).toBe(origFlip);
|
||||
expect(cautionBtn.textContent).toBe(origCaution);
|
||||
});
|
||||
|
||||
it("clicking the tooltip closes caution", () => {
|
||||
openCaution();
|
||||
cautionEffect.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(false);
|
||||
});
|
||||
|
||||
it("FLIP click when caution open (btn-disabled) does nothing", () => {
|
||||
openCaution();
|
||||
var flipBtn = testDiv.querySelector(".sig-flip-btn");
|
||||
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-stage").classList.contains("sig-caution-open")).toBe(true);
|
||||
expect(statBlock.classList.contains("is-reversed")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Stat block: keyword population and FLIP toggle ────────────────── //
|
||||
|
||||
describe("stat block and FLIP", () => {
|
||||
beforeEach(() => makeFixture());
|
||||
|
||||
it("populates upright keywords when a card is hovered", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
var items = statBlock.querySelectorAll("#id_stat_keywords_upright li");
|
||||
expect(items.length).toBe(3);
|
||||
expect(items[0].textContent).toBe("action");
|
||||
expect(items[1].textContent).toBe("impulsiveness");
|
||||
expect(items[2].textContent).toBe("ambition");
|
||||
});
|
||||
|
||||
it("populates reversed keywords when a card is hovered", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
var items = statBlock.querySelectorAll("#id_stat_keywords_reversed li");
|
||||
expect(items.length).toBe(2);
|
||||
expect(items[0].textContent).toBe("no direction");
|
||||
expect(items[1].textContent).toBe("disregard for consequences");
|
||||
});
|
||||
|
||||
it("FLIP click adds .is-reversed to the stat block", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
var flipBtn = statBlock.querySelector(".sig-flip-btn");
|
||||
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(statBlock.classList.contains("is-reversed")).toBe(true);
|
||||
});
|
||||
|
||||
it("second FLIP click removes .is-reversed", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
var flipBtn = statBlock.querySelector(".sig-flip-btn");
|
||||
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
flipBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
expect(statBlock.classList.contains("is-reversed")).toBe(false);
|
||||
});
|
||||
|
||||
it("hovering a new card resets .is-reversed", () => {
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
statBlock.querySelector(".sig-flip-btn").dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true })
|
||||
);
|
||||
expect(statBlock.classList.contains("is-reversed")).toBe(true);
|
||||
|
||||
// Leave and re-enter (simulates moving to a different card)
|
||||
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(statBlock.classList.contains("is-reversed")).toBe(false);
|
||||
});
|
||||
|
||||
it("card with no keywords yields empty lists", () => {
|
||||
card.dataset.keywordsUpright = "";
|
||||
card.dataset.keywordsReversed = "";
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(statBlock.querySelectorAll("#id_stat_keywords_upright li").length).toBe(0);
|
||||
expect(statBlock.querySelectorAll("#id_stat_keywords_reversed li").length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── WS cursor hover (applyHover) ──────────────────────────────────────── //
|
||||
//
|
||||
// Fixture polarity = levity, userRole = PC.
|
||||
// POLARITY_ROLES: levity → [PC, NC, SC] = [left, mid, right]
|
||||
//
|
||||
// Only tests the JS position mapping — colour is CSS-only.
|
||||
|
||||
describe("WS cursor hover", () => {
|
||||
beforeEach(() => makeFixture());
|
||||
|
||||
it("NC hover activates the --mid cursor", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||
detail: { card_id: 42, role: "NC", active: true },
|
||||
}));
|
||||
expect(card.querySelector(".sig-cursor--mid").classList.contains("active")).toBe(true);
|
||||
});
|
||||
|
||||
it("SC hover activates the --right cursor", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||
detail: { card_id: 42, role: "SC", active: true },
|
||||
}));
|
||||
expect(card.querySelector(".sig-cursor--right").classList.contains("active")).toBe(true);
|
||||
});
|
||||
|
||||
it("own role (PC) hover event is ignored — no cursor activates", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||
detail: { card_id: 42, role: "PC", active: true },
|
||||
}));
|
||||
expect(card.querySelectorAll(".sig-cursor.active").length).toBe(0);
|
||||
});
|
||||
|
||||
it("hover-off removes .active from the cursor", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||
detail: { card_id: 42, role: "NC", active: true },
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||
detail: { card_id: 42, role: "NC", active: false },
|
||||
}));
|
||||
expect(card.querySelector(".sig-cursor--mid").classList.contains("active")).toBe(false);
|
||||
});
|
||||
|
||||
it("hover on unknown card_id is a no-op", () => {
|
||||
expect(() => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||
detail: { card_id: 9999, role: "NC", active: true },
|
||||
}));
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── WS reservation — data-reserved-by attribute ───────────────────────── //
|
||||
//
|
||||
// applyReservation() sets data-reserved-by so the CSS can glow the card in
|
||||
// the reserving gamer's role colour. These tests assert the attribute, not
|
||||
// the colour (CSS variables aren't resolvable in the SpecRunner context).
|
||||
|
||||
describe("WS reservation sets data-reserved-by", () => {
|
||||
beforeEach(() => makeFixture());
|
||||
|
||||
it("peer reservation sets data-reserved-by to the reserving role", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "NC", reserved: true },
|
||||
}));
|
||||
expect(card.dataset.reservedBy).toBe("NC");
|
||||
});
|
||||
|
||||
it("peer reservation also adds .sig-reserved class", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "NC", reserved: true },
|
||||
}));
|
||||
expect(card.classList.contains("sig-reserved")).toBe(true);
|
||||
});
|
||||
|
||||
it("release removes data-reserved-by", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "NC", reserved: true },
|
||||
}));
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "NC", reserved: false },
|
||||
}));
|
||||
expect(card.dataset.reservedBy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("own reservation (PC) sets data-reserved-by AND .sig-reserved--own", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "PC", reserved: true },
|
||||
}));
|
||||
expect(card.dataset.reservedBy).toBe("PC");
|
||||
expect(card.classList.contains("sig-reserved--own")).toBe(true);
|
||||
});
|
||||
|
||||
it("peer reservation places a thumbs-up float and removes any hand-pointer float", () => {
|
||||
// First, a hover float exists for NC (mid cursor)
|
||||
window.dispatchEvent(new CustomEvent("room:sig_hover", {
|
||||
detail: { card_id: 42, role: "NC", active: true },
|
||||
}));
|
||||
expect(document.querySelector('.sig-cursor-float[data-role="NC"]')).not.toBeNull();
|
||||
expect(document.querySelector('.fa-hand-pointer[data-role="NC"]')).not.toBeNull();
|
||||
|
||||
// NC then clicks OK — reservation arrives
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "NC", reserved: true },
|
||||
}));
|
||||
|
||||
// Thumbs-up replaces hand-pointer
|
||||
const floatEl = document.querySelector('.sig-cursor-float[data-role="NC"]');
|
||||
expect(floatEl).not.toBeNull();
|
||||
expect(floatEl.classList.contains("fa-thumbs-up")).toBe(true);
|
||||
expect(floatEl.classList.contains("fa-hand-pointer")).toBe(false);
|
||||
});
|
||||
|
||||
it("peer release removes the thumbs-up float", () => {
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "NC", reserved: true },
|
||||
}));
|
||||
expect(document.querySelector('.sig-cursor-float--reserved[data-role="NC"]')).not.toBeNull();
|
||||
|
||||
window.dispatchEvent(new CustomEvent("room:sig_reserved", {
|
||||
detail: { card_id: 42, role: "NC", reserved: false },
|
||||
}));
|
||||
expect(document.querySelector('.sig-cursor-float--reserved[data-role="NC"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Polarity theming — stage qualifier text ────────────────────────────── //
|
||||
//
|
||||
// On mouseenter, updateStage() injects "Leavened" or "Graven" into the
|
||||
// sig-qualifier-above (non-major) or sig-qualifier-below (major arcana) slot.
|
||||
// Correspondence field is never populated in sig-select context.
|
||||
|
||||
describe("polarity theming — stage qualifier", () => {
|
||||
it("levity non-major card puts 'Leavened' in qualifier-above, qualifier-below empty", () => {
|
||||
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||
// data-arcana defaults to "Minor Arcana" in fixture → non-major
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Leavened");
|
||||
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("");
|
||||
});
|
||||
|
||||
it("levity major arcana card puts 'Leavened' in qualifier-below, qualifier-above empty", () => {
|
||||
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||
card.dataset.arcana = "Major Arcana";
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
|
||||
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened");
|
||||
});
|
||||
|
||||
it("major arcana title gets a trailing comma (qualifier reads as subtitle)", () => {
|
||||
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||
card.dataset.arcana = "Major Arcana";
|
||||
card.dataset.nameTitle = "The Schizo";
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".fan-card-name").textContent).toBe("The Schizo,");
|
||||
});
|
||||
|
||||
it("non-major arcana title has no trailing comma", () => {
|
||||
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||
// fixture default: Minor Arcana, "King of Pentacles"
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".fan-card-name").textContent).toBe("King of Pentacles");
|
||||
});
|
||||
|
||||
it("gravity non-major card puts 'Graven' in qualifier-above", () => {
|
||||
makeFixture({ polarity: 'gravity', userRole: 'BC' });
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("Graven");
|
||||
});
|
||||
|
||||
it("gravity major arcana card puts 'Graven' in qualifier-below", () => {
|
||||
makeFixture({ polarity: 'gravity', userRole: 'BC' });
|
||||
card.dataset.arcana = "Major Arcana";
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Graven");
|
||||
});
|
||||
|
||||
it("hovering clears qualifier slots from the previous card", () => {
|
||||
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
card.dataset.arcana = "Major Arcana";
|
||||
card.dispatchEvent(new MouseEvent("mouseleave", { bubbles: true }));
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
// Now major — above should be empty, below filled
|
||||
expect(testDiv.querySelector(".sig-qualifier-above").textContent).toBe("");
|
||||
expect(testDiv.querySelector(".sig-qualifier-below").textContent).toBe("Leavened");
|
||||
});
|
||||
|
||||
it("correspondence field is never populated", () => {
|
||||
makeFixture({ polarity: 'levity', userRole: 'PC' });
|
||||
card.dataset.correspondence = "Il Bagatto (Minchiate)";
|
||||
card.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
|
||||
expect(testDiv.querySelector(".fan-card-correspondence").textContent).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,10 +21,12 @@
|
||||
<script src="Spec.js"></script>
|
||||
<script src="RoleSelectSpec.js"></script>
|
||||
<script src="TraySpec.js"></script>
|
||||
<script src="SigSelectSpec.js"></script>
|
||||
<!-- src files -->
|
||||
<script src="/static/apps/dashboard/dashboard.js"></script>
|
||||
<script src="/static/apps/epic/role-select.js"></script>
|
||||
<script src="/static/apps/epic/tray.js"></script>
|
||||
<script src="/static/apps/epic/sig-select.js"></script>
|
||||
<!-- Jasmine env config (optional) -->
|
||||
<script src="lib/jasmine-6.0.1/boot1.js"></script>
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<div id="id_billboard_applets_container">
|
||||
<div id="id_billboard_applet_menu" style="display:none;">
|
||||
<div id="id_billboard_applet_menu" style="display:none;">
|
||||
<form
|
||||
hx-post="{% url "billboard:toggle_applets" %}"
|
||||
hx-target="#id_billboard_applets_container"
|
||||
@@ -22,6 +21,7 @@
|
||||
<button type="button" class="btn btn-cancel applet-menu-cancel">NVM</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div id="id_billboard_applets_container">
|
||||
{% include "apps/applets/_partials/_applets.html" %}
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-xl">Share</button>
|
||||
<button type="submit" class="btn btn-primary">Share</button>
|
||||
</form>
|
||||
<small>Note shared with:
|
||||
{% for user in note.shared_with.all %}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<div class="gate-overlay">
|
||||
<div class="gate-modal" role="dialog" aria-label="Gatekeeper">
|
||||
|
||||
<div class="gate-title-panel">
|
||||
<header class="gate-header">
|
||||
<h1>{{ room.name }}</h1>
|
||||
<div class="gate-status-wrap">
|
||||
@@ -15,7 +16,10 @@
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="gate-top-row">
|
||||
<div class="gate-main-panel">
|
||||
<div class="token-slot{% if can_drop %} active{% elif user_reserved_slot %} pending{% elif user_filled_slot or carte_active %} claimed{% elif token_depleted %} depleted{% else %} locked{% endif %}">
|
||||
{% if can_drop %}
|
||||
<form method="POST" action="{% url 'epic:drop_token' room.id %}" style="display:contents">
|
||||
@@ -43,16 +47,20 @@
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="gate-roles-panel">
|
||||
{% if room.gate_status == 'OPEN' %}
|
||||
<form method="POST" action="{% url 'epic:pick_roles' room.id %}" style="display:contents">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="launch-game-btn btn btn-primary btn-xl">PICK ROLES</button>
|
||||
<button type="submit" class="launch-game-btn btn btn-primary">PICK ROLES</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if request.user == room.owner %}
|
||||
<div class="form-container">
|
||||
<div class="gate-invite-panel">
|
||||
<h3>Invite Friend</h3>
|
||||
<form method="POST" action="{% url 'epic:invite_gamer' room.id %}" style="display:flex; gap:0.5rem; align-items:center;">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
{% load i18n %}{% comment %}
|
||||
Sig Select overlay — dark Gaussian modal over the dormant table hex.
|
||||
Rendered for the current user's polarity group only.
|
||||
Context: sig_cards, user_polarity, user_seat, sig_reserve_url, sig_reservations_json
|
||||
{% endcomment %}
|
||||
<div class="sig-backdrop"></div>
|
||||
<div class="sig-overlay"
|
||||
data-polarity="{{ user_polarity }}"
|
||||
data-user-role="{{ user_seat.role }}"
|
||||
data-reserve-url="{{ sig_reserve_url }}"
|
||||
data-reservations="{{ sig_reservations_json }}">
|
||||
|
||||
<div class="sig-modal">
|
||||
|
||||
<div class="sig-stage" id="id_sig_stage">
|
||||
|
||||
<div class="sig-stage-card" style="display:none">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank"></span>
|
||||
<i class="fa-solid stage-suit-icon" style="display:none"></i>
|
||||
</div>
|
||||
<div class="fan-card-face">
|
||||
<p class="fan-card-name-group"></p>
|
||||
<p class="sig-qualifier-above"></p>
|
||||
<h3 class="fan-card-name"></h3>
|
||||
<p class="sig-qualifier-below"></p>
|
||||
<p class="fan-card-arcana"></p>
|
||||
<p class="fan-card-correspondence"></p>{# not shown in sig-select — game-kit only #}
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank"></span>
|
||||
<i class="fa-solid stage-suit-icon" style="display:none"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sig-stat-block">
|
||||
<button class="btn btn-reverse sig-flip-btn" type="button">FLIP</button>
|
||||
<button class="btn btn-caution sig-caution-btn" type="button">FYI</button>
|
||||
<div class="stat-face stat-face--upright">
|
||||
<p class="stat-face-label">Upright</p>
|
||||
<ul class="stat-keywords" id="id_stat_keywords_upright"></ul>
|
||||
</div>
|
||||
<div class="stat-face stat-face--reversed">
|
||||
<p class="stat-face-label">Reversed</p>
|
||||
<ul class="stat-keywords" id="id_stat_keywords_reversed"></ul>
|
||||
</div>
|
||||
<div class="sig-caution-tooltip" id="id_sig_caution">
|
||||
<div class="sig-caution-header">
|
||||
<h4 class="sig-caution-title">Caution!</h4>
|
||||
<p class="sig-caution-type">Rival Interaction</p>
|
||||
</div>
|
||||
<p class="sig-caution-shoptalk">[Shoptalk forthcoming]</p>
|
||||
<p class="sig-caution-effect"></p>
|
||||
<span class="sig-caution-index"></span>
|
||||
</div>
|
||||
<button class="btn btn-nav-left sig-caution-prev" type="button">PRV</button>
|
||||
<button class="btn btn-nav-right sig-caution-next" type="button">NXT</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sig-deck-grid" id="id_sig_deck">
|
||||
{% for card in sig_cards %}
|
||||
<div class="sig-card {{ user_polarity }}-deck"
|
||||
data-card-id="{{ card.id }}"
|
||||
data-suit-icon="{{ card.suit_icon }}"
|
||||
data-corner-rank="{{ card.corner_rank }}"
|
||||
data-name-group="{{ card.name_group }}"
|
||||
data-name-title="{{ card.name_title }}"
|
||||
data-arcana="{{ card.get_arcana_display }}"
|
||||
data-correspondence="{{ card.correspondence|default:'' }}"
|
||||
data-keywords-upright="{{ card.keywords_upright|join:',' }}"
|
||||
data-keywords-reversed="{{ card.keywords_reversed|join:',' }}"
|
||||
data-cautions="{{ card.cautions_json }}">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
<div class="sig-card-actions">
|
||||
<button class="sig-ok-btn btn btn-confirm" type="button">OK</button>
|
||||
<button class="sig-nvm-btn btn btn-cancel" type="button">NVM</button>
|
||||
</div>
|
||||
<div class="sig-card-cursors">
|
||||
<i class="fa-solid fa-hand-pointer sig-cursor sig-cursor--left"></i>
|
||||
<i class="fa-solid fa-hand-pointer sig-cursor sig-cursor--mid"></i>
|
||||
<i class="fa-solid fa-hand-pointer sig-cursor sig-cursor--right"></i>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -8,7 +8,7 @@
|
||||
<form method="POST" action="{% url 'epic:confirm_token' room.id %}">
|
||||
{% csrf_token %}
|
||||
{% if is_last_slot %}
|
||||
<button type="submit" class="btn btn-primary btn-xl">PICK ROLES</button>
|
||||
<button type="submit" class="btn btn-primary">PICK ROLES</button>
|
||||
{% else %}
|
||||
<button type="submit" class="btn btn-confirm">OK</button>
|
||||
{% endif %}
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
{% block content %}
|
||||
<div class="room-page" data-room-id="{{ room.id }}"
|
||||
{% if room.table_status %}data-select-role-url="{% url 'epic:select_role' room.id %}"{% endif %}>
|
||||
<div id="id_aperture_fill"></div>
|
||||
<div class="room-shell">
|
||||
<div id="id_game_table" class="room-table">
|
||||
{% if room.table_status == "ROLE_SELECT" %}
|
||||
<div id="id_pick_sigs_wrap"{% if starter_roles|length < 6 %} style="display:none"{% endif %}>
|
||||
<form method="POST" action="{% url 'epic:pick_sigs' room.id %}">
|
||||
{% csrf_token %}
|
||||
<button id="id_pick_sigs_btn" type="submit" class="btn btn-primary btn-xl">PICK<br>SIGS</button>
|
||||
<button id="id_pick_sigs_btn" type="submit" class="btn btn-primary">PICK<br>SIGS</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -36,17 +37,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if room.table_status == "SIG_SELECT" and sig_seats %}
|
||||
{% for seat in sig_seats %}
|
||||
<div class="table-seat{% if seat == sig_active_seat %} active{% endif %}" data-role="{{ seat.role }}" data-slot="{{ seat.slot_number }}">
|
||||
<div class="seat-portrait">{{ seat.slot_number }}</div>
|
||||
<div class="seat-card-arc"></div>
|
||||
<span class="seat-label">
|
||||
{% if seat.gamer %}@{{ seat.gamer.username|default:seat.gamer.email }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{# Seats — fa-chair layout persists from ROLE_SELECT through SIG_SELECT #}
|
||||
{% for pos in gate_positions %}
|
||||
<div class="table-seat{% if pos.role_label in starter_roles %} role-confirmed{% endif %}"
|
||||
data-slot="{{ pos.slot.slot_number }}" data-role="{{ pos.role_label }}">
|
||||
@@ -59,33 +50,13 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if room.table_status == "SIG_SELECT" and sig_cards %}
|
||||
<div id="id_sig_deck"
|
||||
data-select-sig-url="{% url 'epic:select_sig' room.id %}"
|
||||
data-user-role="{{ user_seat.role|default:'' }}">
|
||||
{% for card, deck_type in sig_cards %}
|
||||
<div class="sig-card {{ deck_type }}-deck" data-card-id="{{ card.id }}" data-deck="{{ deck_type }}">
|
||||
<div class="fan-card-corner fan-card-corner--tl">
|
||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
<div class="fan-card-face">
|
||||
{% if card.name_group %}<p class="fan-card-name-group">{{ card.name_group }}</p>{% endif %}
|
||||
<h3 class="fan-card-name">{{ card.name_title }}</h3>
|
||||
<p class="fan-card-arcana">{{ card.get_arcana_display }}</p>
|
||||
</div>
|
||||
<div class="fan-card-corner fan-card-corner--br">
|
||||
<span class="fan-corner-rank">{{ card.corner_rank }}</span>
|
||||
{% if card.suit_icon %}<i class="fa-solid {{ card.suit_icon }}"></i>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{# Sig Select overlay — only shown to seated gamers in this polarity #}
|
||||
{% if room.table_status == "SIG_SELECT" and user_polarity %}
|
||||
{% include "apps/gameboard/_partials/_sig_select_overlay.html" %}
|
||||
{% endif %}
|
||||
|
||||
{% if room.gate_status != "RENEWAL_DUE" and room.table_status != "SIG_SELECT" %}
|
||||
@@ -102,7 +73,7 @@
|
||||
<i class="fa-solid fa-dice-d20"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="id_tray" style="display:none"><div id="id_tray_grid">{% for i in "12345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
|
||||
<div id="id_tray" style="display:none"><div id="id_tray_grid" data-role-icons-url="{% static 'apps/epic/icons/cards-roles/' %}">{% if my_tray_role %}<div class="tray-cell tray-role-card" data-role="{{ my_tray_role }}"><img src="{% static my_tray_scrawl_static_path %}" alt="{{ my_tray_role }}"></div>{% else %}<div class="tray-cell"></div>{% endif %}{% for i in "2345678" %}<div class="tray-cell"></div>{% endfor %}</div></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include "apps/gameboard/_partials/_room_gear.html" %}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<h1>Welcome,<br>Earthman</h1>
|
||||
</a>
|
||||
{% if user.email %}
|
||||
<div class="navbar-user">
|
||||
<div class="navbar-text">
|
||||
<span class="navbar-label">
|
||||
Logged in as
|
||||
@@ -15,10 +16,22 @@
|
||||
</div>
|
||||
<form method="POST" action="{% url "logout" %}">
|
||||
{% csrf_token %}
|
||||
<button id="id_logout" class="btn btn-primary btn-xl" type="submit" data-confirm="Log out?">
|
||||
Log Out
|
||||
<button id="id_logout" class="btn btn-abandon" type="submit" data-confirm="Log out?">
|
||||
BYE
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% if navbar_recent_room_url %}
|
||||
<button
|
||||
id="id_cont_game"
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
data-confirm="Continue game?"
|
||||
data-href="{{ navbar_recent_room_url }}"
|
||||
>
|
||||
CONT<br>GAME
|
||||
</button>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<form method="POST" action="{% url "send_login_email" %}">
|
||||
<div class="input-group">
|
||||
|
||||
@@ -73,8 +73,9 @@
|
||||
var _cb = null;
|
||||
var _onDismiss = null;
|
||||
|
||||
function show(anchor, message, callback, onDismiss) {
|
||||
function show(anchor, message, callback, onDismiss, options) {
|
||||
if (!portal) return;
|
||||
options = options || {};
|
||||
_cb = callback;
|
||||
_onDismiss = onDismiss || null;
|
||||
portal.querySelector('.guard-message').innerHTML = message;
|
||||
@@ -85,12 +86,16 @@
|
||||
var cleft = Math.max(pw / 2 + 8, Math.min(rawLeft, window.innerWidth - pw / 2 - 8));
|
||||
portal.style.left = Math.round(cleft) + 'px';
|
||||
var cardCenterY = rect.top + rect.height / 2;
|
||||
if (cardCenterY < window.innerHeight / 2) {
|
||||
portal.style.top = Math.round(rect.top) + 'px';
|
||||
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
|
||||
} else {
|
||||
// Default: upper half → below (avoids viewport top edge for navbar/fixed buttons).
|
||||
// invertY: upper half → above (for modal grids where tooltip should fly away from centre).
|
||||
var showBelow = (cardCenterY < window.innerHeight / 2);
|
||||
if (options.invertY) showBelow = !showBelow;
|
||||
if (showBelow) {
|
||||
portal.style.top = Math.round(rect.bottom) + 'px';
|
||||
portal.style.transform = 'translate(-50%, 0.5rem)';
|
||||
} else {
|
||||
portal.style.top = Math.round(rect.top) + 'px';
|
||||
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +145,7 @@
|
||||
var form = btn.closest('form');
|
||||
show(btn, btn.dataset.confirm, function () {
|
||||
if (form) form.submit();
|
||||
else if (btn.dataset.href) window.location.href = btn.dataset.href;
|
||||
});
|
||||
}, true);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user