2026-03-09 01:07:16 -04:00
|
|
|
from django.contrib.auth.decorators import login_required
|
2026-03-16 00:07:52 -04:00
|
|
|
from django.http import HttpResponse
|
|
|
|
|
from django.shortcuts import get_object_or_404, redirect, render
|
2026-03-15 16:08:34 -04:00
|
|
|
from django.utils import timezone
|
2026-03-09 01:07:16 -04:00
|
|
|
|
2026-04-21 15:46:30 -04:00
|
|
|
from apps.applets.utils import applet_context, apply_applet_toggle
|
2026-04-27 23:24:43 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _annotate_deck_in_use(decks, user):
|
|
|
|
|
"""Attach .in_use_room_name to each deck — the name of the active room using it, or None."""
|
|
|
|
|
active = {
|
|
|
|
|
ts.deck_variant_id: ts.room.name
|
|
|
|
|
for ts in TableSeat.objects.filter(
|
|
|
|
|
gamer=user, deck_variant__isnull=False,
|
|
|
|
|
).select_related("room")
|
|
|
|
|
}
|
|
|
|
|
for deck in decks:
|
|
|
|
|
deck.in_use_room_name = active.get(deck.pk)
|
|
|
|
|
return decks
|
|
|
|
|
from apps.epic.models import DeckVariant, Room, TableSeat
|
applet rows: 3-col grid `<title> | <body> | <ts>` mirroring post.html's `.post-line` shape — `_my_posts_applet_item / _my_buds_applet_item / _my_notes_item / _my_scrolls_item / _my_games_item` all gain a `.applet-list-entry.row-3col` w. `<a class="row-title">` (clickable, 35c/32+... server-side truncated via new `lyric_extras.truncate_title` filter) + `<span class="row-body">` (most-recent activity excerpt, dimmed 0.6 opacity, CSS-`text-overflow: ellipsis` clipped to whatever space remains — no server-side trunc here so the full line lives in the DOM for inspectors) + `<time class="row-ts">` (`relative_ts` formatted, same `minmax(3rem,auto)` rightward column allocation post.html's `.post-line-time` uses, font-size 0.75rem + opacity 0.5 + right-aligned + nowrap); SCSS grid `minmax(4rem,auto) 1fr minmax(3rem,auto)` lifted from `.post-line`'s template so the timestamp column lines up across post.html / scroll.html / every applet list; per-applet data shapes — `_recent_posts` annotates each Post w. `latest_line` (Line FK ordered by -id, None for empty Note-unlock posts); `_recent_buds` `select_related('to_user__active_title')` warms the bud's donned-Note FK in one query for the buds row body ("the {{ bud.active_title_display }}" + "since {{ bud.active_title.earned_at|relative_ts }}" — the "since " prefix is unique to this row since the ts is "when they donned it", not the row's own creation); `_recent_notes` attaches `description` from `_NOTE_META` per slug; `annotate_latest_event(rooms)` helper added to `apps.epic.utils` (next to `rooms_for_user`) — attaches `room.latest_event` per Room w. one `.events.order_by('-timestamp').first()` per item, used by `_billboard_context` for `my_rooms` (My Scrolls applet) AND by `apps.gameboard.views.gameboard` + `toggle_game_applets` for `my_games` (My Games applet), keeping the My Scrolls + My Games shapes symmetric; `_billboard_context.my_rooms = annotate_latest_event(...)` swaps `rooms_for_user(...).order_by("-created_at")` materialisation point — bud row's "no active title" branch silently drops body + ts cells so unrecognised buds still surface but don't fabricate a "since None" line; new `truncate_title` filter is the existing `_truncate_post_title` view helper hoisted into the template namespace (literal `...` past 35 chars, None-safe); 5 ITs in BillboardViewTest cover row content / row absence on missing activity / "since" prefix uniquely on the buds row + 1 in GameboardViewTest for My Games row event prose; deferred row-prose body content cap on `<span class="row-body">` purely to CSS `text-overflow: ellipsis` per user's "middle col should take up the remaining space" steer (initial pass also server-side trunc'd the body to 35c; removed) — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 23:06:55 -04:00
|
|
|
from apps.epic.utils import annotate_latest_event, rooms_for_user
|
2026-03-09 01:07:16 -04:00
|
|
|
from apps.lyric.models import Token
|
|
|
|
|
|
|
|
|
|
|
2026-03-09 21:13:35 -04:00
|
|
|
GAMEBOARD_APPLET_ORDER = [
|
|
|
|
|
"new-game",
|
|
|
|
|
"my-games",
|
|
|
|
|
"game-kit",
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-03-09 01:07:16 -04:00
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def gameboard(request):
|
2026-03-15 01:17:09 -04:00
|
|
|
pass_token = request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None
|
2026-03-09 01:07:16 -04:00
|
|
|
coin = request.user.tokens.filter(token_type=Token.COIN).first()
|
2026-03-16 00:07:52 -04:00
|
|
|
carte = request.user.tokens.filter(token_type=Token.CARTE).first()
|
2026-03-15 16:08:34 -04:00
|
|
|
free_tokens = list(request.user.tokens.filter(
|
|
|
|
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
|
|
|
|
).order_by("expires_at"))
|
2026-03-09 01:07:16 -04:00
|
|
|
return render(
|
|
|
|
|
request, "apps/gameboard/gameboard.html", {
|
2026-03-15 01:17:09 -04:00
|
|
|
"pass_token": pass_token,
|
2026-03-09 01:07:16 -04:00
|
|
|
"coin": coin,
|
2026-03-16 00:07:52 -04:00
|
|
|
"carte": carte,
|
2026-04-16 00:14:47 -04:00
|
|
|
"equipped_trinket_id": request.user.equipped_trinket_id,
|
|
|
|
|
"equipped_deck_id": request.user.equipped_deck_id,
|
2026-04-27 23:24:43 -04:00
|
|
|
"deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user),
|
2026-03-09 01:07:16 -04:00
|
|
|
"free_tokens": free_tokens,
|
2026-03-15 16:08:34 -04:00
|
|
|
"free_count": len(free_tokens),
|
2026-03-09 21:13:35 -04:00
|
|
|
"applets": applet_context(request.user, "gameboard"),
|
2026-03-09 21:52:54 -04:00
|
|
|
"page_class": "page-gameboard",
|
applet rows: 3-col grid `<title> | <body> | <ts>` mirroring post.html's `.post-line` shape — `_my_posts_applet_item / _my_buds_applet_item / _my_notes_item / _my_scrolls_item / _my_games_item` all gain a `.applet-list-entry.row-3col` w. `<a class="row-title">` (clickable, 35c/32+... server-side truncated via new `lyric_extras.truncate_title` filter) + `<span class="row-body">` (most-recent activity excerpt, dimmed 0.6 opacity, CSS-`text-overflow: ellipsis` clipped to whatever space remains — no server-side trunc here so the full line lives in the DOM for inspectors) + `<time class="row-ts">` (`relative_ts` formatted, same `minmax(3rem,auto)` rightward column allocation post.html's `.post-line-time` uses, font-size 0.75rem + opacity 0.5 + right-aligned + nowrap); SCSS grid `minmax(4rem,auto) 1fr minmax(3rem,auto)` lifted from `.post-line`'s template so the timestamp column lines up across post.html / scroll.html / every applet list; per-applet data shapes — `_recent_posts` annotates each Post w. `latest_line` (Line FK ordered by -id, None for empty Note-unlock posts); `_recent_buds` `select_related('to_user__active_title')` warms the bud's donned-Note FK in one query for the buds row body ("the {{ bud.active_title_display }}" + "since {{ bud.active_title.earned_at|relative_ts }}" — the "since " prefix is unique to this row since the ts is "when they donned it", not the row's own creation); `_recent_notes` attaches `description` from `_NOTE_META` per slug; `annotate_latest_event(rooms)` helper added to `apps.epic.utils` (next to `rooms_for_user`) — attaches `room.latest_event` per Room w. one `.events.order_by('-timestamp').first()` per item, used by `_billboard_context` for `my_rooms` (My Scrolls applet) AND by `apps.gameboard.views.gameboard` + `toggle_game_applets` for `my_games` (My Games applet), keeping the My Scrolls + My Games shapes symmetric; `_billboard_context.my_rooms = annotate_latest_event(...)` swaps `rooms_for_user(...).order_by("-created_at")` materialisation point — bud row's "no active title" branch silently drops body + ts cells so unrecognised buds still surface but don't fabricate a "since None" line; new `truncate_title` filter is the existing `_truncate_post_title` view helper hoisted into the template namespace (literal `...` past 35 chars, None-safe); 5 ITs in BillboardViewTest cover row content / row absence on missing activity / "since" prefix uniquely on the buds row + 1 in GameboardViewTest for My Games row event prose; deferred row-prose body content cap on `<span class="row-body">` purely to CSS `text-overflow: ellipsis` per user's "middle col should take up the remaining space" steer (initial pass also server-side trunc'd the body to 35c; removed) — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 23:06:55 -04:00
|
|
|
"my_games": annotate_latest_event(rooms_for_user(request.user)),
|
2026-03-09 01:07:16 -04:00
|
|
|
}
|
|
|
|
|
)
|
2026-03-09 21:13:35 -04:00
|
|
|
|
|
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def toggle_game_applets(request):
|
|
|
|
|
checked = request.POST.getlist("applets")
|
2026-04-21 15:46:30 -04:00
|
|
|
apply_applet_toggle(request.user, "gameboard", checked)
|
2026-03-09 21:13:35 -04:00
|
|
|
if request.headers.get("HX-Request"):
|
2026-04-21 15:46:30 -04:00
|
|
|
free_tokens = list(request.user.tokens.filter(
|
|
|
|
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
|
|
|
|
).order_by("expires_at"))
|
2026-03-09 21:13:35 -04:00
|
|
|
return render(request, "apps/gameboard/_partials/_applets.html", {
|
|
|
|
|
"applets": applet_context(request.user, "gameboard"),
|
2026-03-15 01:17:09 -04:00
|
|
|
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
|
2026-03-09 21:13:35 -04:00
|
|
|
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
2026-03-16 00:07:52 -04:00
|
|
|
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
|
2026-04-16 00:14:47 -04:00
|
|
|
"equipped_trinket_id": request.user.equipped_trinket_id,
|
|
|
|
|
"equipped_deck_id": request.user.equipped_deck_id,
|
2026-04-27 23:24:43 -04:00
|
|
|
"deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user),
|
2026-04-21 15:46:30 -04:00
|
|
|
"free_tokens": free_tokens,
|
|
|
|
|
"free_count": len(free_tokens),
|
applet rows: 3-col grid `<title> | <body> | <ts>` mirroring post.html's `.post-line` shape — `_my_posts_applet_item / _my_buds_applet_item / _my_notes_item / _my_scrolls_item / _my_games_item` all gain a `.applet-list-entry.row-3col` w. `<a class="row-title">` (clickable, 35c/32+... server-side truncated via new `lyric_extras.truncate_title` filter) + `<span class="row-body">` (most-recent activity excerpt, dimmed 0.6 opacity, CSS-`text-overflow: ellipsis` clipped to whatever space remains — no server-side trunc here so the full line lives in the DOM for inspectors) + `<time class="row-ts">` (`relative_ts` formatted, same `minmax(3rem,auto)` rightward column allocation post.html's `.post-line-time` uses, font-size 0.75rem + opacity 0.5 + right-aligned + nowrap); SCSS grid `minmax(4rem,auto) 1fr minmax(3rem,auto)` lifted from `.post-line`'s template so the timestamp column lines up across post.html / scroll.html / every applet list; per-applet data shapes — `_recent_posts` annotates each Post w. `latest_line` (Line FK ordered by -id, None for empty Note-unlock posts); `_recent_buds` `select_related('to_user__active_title')` warms the bud's donned-Note FK in one query for the buds row body ("the {{ bud.active_title_display }}" + "since {{ bud.active_title.earned_at|relative_ts }}" — the "since " prefix is unique to this row since the ts is "when they donned it", not the row's own creation); `_recent_notes` attaches `description` from `_NOTE_META` per slug; `annotate_latest_event(rooms)` helper added to `apps.epic.utils` (next to `rooms_for_user`) — attaches `room.latest_event` per Room w. one `.events.order_by('-timestamp').first()` per item, used by `_billboard_context` for `my_rooms` (My Scrolls applet) AND by `apps.gameboard.views.gameboard` + `toggle_game_applets` for `my_games` (My Games applet), keeping the My Scrolls + My Games shapes symmetric; `_billboard_context.my_rooms = annotate_latest_event(...)` swaps `rooms_for_user(...).order_by("-created_at")` materialisation point — bud row's "no active title" branch silently drops body + ts cells so unrecognised buds still surface but don't fabricate a "since None" line; new `truncate_title` filter is the existing `_truncate_post_title` view helper hoisted into the template namespace (literal `...` past 35 chars, None-safe); 5 ITs in BillboardViewTest cover row content / row absence on missing activity / "since" prefix uniquely on the buds row + 1 in GameboardViewTest for My Games row event prose; deferred row-prose body content cap on `<span class="row-body">` purely to CSS `text-overflow: ellipsis` per user's "middle col should take up the remaining space" steer (initial pass also server-side trunc'd the body to 35c; removed) — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 23:06:55 -04:00
|
|
|
"my_games": annotate_latest_event(rooms_for_user(request.user)),
|
2026-03-09 21:13:35 -04:00
|
|
|
})
|
|
|
|
|
return redirect("gameboard")
|
2026-03-16 00:07:52 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def equip_trinket(request, token_id):
|
|
|
|
|
token = get_object_or_404(Token, pk=token_id, user=request.user)
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
request.user.equipped_trinket = token
|
|
|
|
|
request.user.save(update_fields=["equipped_trinket"])
|
|
|
|
|
return HttpResponse(status=204)
|
|
|
|
|
return render(
|
|
|
|
|
request,
|
|
|
|
|
"apps/gameboard/_partials/_equip_trinket_btn.html",
|
|
|
|
|
{"token": token},
|
|
|
|
|
)
|
2026-03-24 21:52:57 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def equip_deck(request, deck_id):
|
|
|
|
|
deck = get_object_or_404(DeckVariant, pk=deck_id)
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
request.user.equipped_deck = deck
|
|
|
|
|
request.user.save(update_fields=["equipped_deck"])
|
|
|
|
|
return HttpResponse(status=204)
|
|
|
|
|
return HttpResponse(status=405)
|
2026-03-24 22:57:12 -04:00
|
|
|
|
|
|
|
|
|
2026-04-16 00:14:47 -04:00
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def unequip_trinket(request, token_id):
|
|
|
|
|
token = get_object_or_404(Token, pk=token_id, user=request.user)
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
if request.user.equipped_trinket_id == token.pk:
|
|
|
|
|
request.user.equipped_trinket = None
|
|
|
|
|
request.user.save(update_fields=["equipped_trinket"])
|
|
|
|
|
return HttpResponse(status=204)
|
|
|
|
|
return HttpResponse(status=405)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def unequip_deck(request, deck_id):
|
|
|
|
|
get_object_or_404(DeckVariant, pk=deck_id)
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
if request.user.equipped_deck_id == deck_id:
|
|
|
|
|
request.user.equipped_deck = None
|
|
|
|
|
request.user.save(update_fields=["equipped_deck"])
|
|
|
|
|
return HttpResponse(status=204)
|
|
|
|
|
return HttpResponse(status=405)
|
|
|
|
|
|
|
|
|
|
|
2026-04-04 13:49:48 -04:00
|
|
|
def _game_kit_context(user):
|
pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD
- User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns
- drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their"
- SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched
- new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio
- card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses
- dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE
- _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question
- billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
|
|
|
from apps.lyric.models import PRONOUN_CHOICES
|
2026-04-04 13:49:48 -04:00
|
|
|
coin = user.tokens.filter(token_type=Token.COIN).first()
|
|
|
|
|
pass_token = user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None
|
|
|
|
|
carte = user.tokens.filter(token_type=Token.CARTE).first()
|
|
|
|
|
free_tokens = list(user.tokens.filter(
|
2026-03-24 22:57:12 -04:00
|
|
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
|
|
|
|
).order_by("expires_at"))
|
2026-04-04 13:49:48 -04:00
|
|
|
tithe_tokens = list(user.tokens.filter(token_type=Token.TITHE))
|
pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD
- User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns
- drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their"
- SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched
- new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio
- card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses
- dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE
- _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question
- billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
|
|
|
pronoun_options = [
|
|
|
|
|
{"key": k, "label": label, "active": (k == user.pronouns)}
|
|
|
|
|
for (k, label) in PRONOUN_CHOICES
|
|
|
|
|
]
|
2026-04-04 13:49:48 -04:00
|
|
|
return {
|
2026-03-24 22:57:12 -04:00
|
|
|
"coin": coin,
|
|
|
|
|
"pass_token": pass_token,
|
|
|
|
|
"carte": carte,
|
|
|
|
|
"free_tokens": free_tokens,
|
|
|
|
|
"tithe_tokens": tithe_tokens,
|
2026-04-04 13:49:48 -04:00
|
|
|
"unlocked_decks": list(user.unlocked_decks.all()),
|
|
|
|
|
"applets": applet_context(user, "game-kit"),
|
pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD
- User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns
- drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their"
- SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched
- new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio
- card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses
- dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE
- _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question
- billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
|
|
|
"pronoun_options": pronoun_options,
|
|
|
|
|
"current_pronouns": user.pronouns,
|
2026-04-04 13:49:48 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def game_kit(request):
|
|
|
|
|
return render(request, "apps/gameboard/game_kit.html", {
|
|
|
|
|
**_game_kit_context(request.user),
|
2026-03-24 22:57:12 -04:00
|
|
|
"page_class": "page-gameboard",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
2026-04-04 13:49:48 -04:00
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def toggle_game_kit_sections(request):
|
|
|
|
|
checked = request.POST.getlist("applets")
|
2026-04-21 15:46:30 -04:00
|
|
|
apply_applet_toggle(request.user, "game-kit", checked)
|
2026-04-04 13:49:48 -04:00
|
|
|
if request.headers.get("HX-Request"):
|
|
|
|
|
return render(request, "apps/gameboard/_partials/_game_kit_sections.html",
|
|
|
|
|
_game_kit_context(request.user))
|
|
|
|
|
return redirect("game_kit")
|
|
|
|
|
|
|
|
|
|
|
My Sea applet shell — Sprint 3 of the My Sea roadmap
User roadmap step (Sprint 3 of cluster): scaffold the My Sea applet on the gameboard + the standalone /gameboard/my-sea/ page where later sprints will host the gatekeeper / sig-select / sea-select reskin for solo-user draws. Shell-only — no draw flow yet; latest-draw rendering, mid-progress save, daily quota land in Sprints 4-9 ; **migration**: `applets/migrations/0008_seed_my_sea_applet.py` — RunPython that `update_or_create`s Applet(`slug='my-sea'`, name='My Sea', context='gameboard', default_visible=True, grid_cols=12, grid_rows=4). 12×4 wide horizontal banner so the Celtic Cross spread's 10 cards can render left-to-right in the applet aperture, scrollable like My Palette (per user spec). Reverse migration (`unseed`) deletes the row so the migration is reversible for staging rollbacks ; **applet partial**: `templates/apps/gameboard/_partials/_applet-my-sea.html` — same `{% applet_context %}` auto-discovery shape every other applet uses (`<section id="id_applet_my_sea" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};">`). Header is a `<h2><a href="{% url 'my_sea' %}">My Sea</a></h2>` link (gold via global `body a` rule); body is a `.my-sea-scroll` container that either renders `.my-sea-card` cells from a `latest_draw_cards` context (TBD in Sprint 4-7) or a `.my-sea-empty` placeholder line "No draws yet." for fresh users ; **standalone page**: new `gameboard/views.py:my_sea` view + url at `/gameboard/my-sea/` (URL name `my_sea`) rendering `apps/gameboard/my_sea.html` — `{% extends "core/base.html" %}` shell w. letter-spread `<span>My</span><span>Sea</span>` h2 wordmark + `.my-sea-page__empty` placeholder paragraph "Your sea is calm. Draws will appear here." `page_class` doubled to `page-gameboard page-my-sea` so the body inherits the gameboard's landscape aperture treatment AND any future my-sea-specific styles can target a single class. Login-required like the rest of gameboard ; **tests (+6 ITs)**: GameboardViewTest gains 3 — `test_gameboard_shows_my_sea_applet` (cssselect pins #id_applet_my_sea), `test_my_sea_applet_renders_empty_state_for_new_user` (asserts ".my-sea-empty" text + no ".my-sea-card" rows), `test_my_sea_applet_header_links_to_my_sea_page` (h2 a href == reverse('my_sea')); new MySeaViewTest class — `test_my_sea_requires_login` (redirect to /?next=...), `test_my_sea_renders_200`, `test_my_sea_uses_gameboard_page_class` (page-gameboard + page-my-sea both in body class). Existing GameboardViewTest setUp already does `get_or_create` per-applet so no fixture change needed for the migration-driven my-sea row ; 1005 IT/UT green (+6 from 999) in 45s; visual verified in Claudezilla at iPhone-14 portrait — applet renders w. rotated "MY SEA" vertical label + "No draws yet." body; /gameboard/my-sea/ standalone page renders w. letter-spread wordmark + placeholder ; **next**: Sprint 4 — My Sea sig-select phase (single-significator pick for solo user, w. the parameterized hex CSS from Sprint 1 hosting the chair-less or single-chair variant)
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:45:57 -04:00
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def my_sea(request):
|
|
|
|
|
"""Shell view for the My Sea standalone page.
|
|
|
|
|
|
2026-05-19 15:15:37 -04:00
|
|
|
Branches three ways:
|
|
|
|
|
|
|
|
|
|
1. No sig → Look!-formatted gate w. FYI/NVM (Sprint 4b).
|
|
|
|
|
2. Sig + equipped deck → DRAW SEA landing (Sprint 5 iter 1) — hex w.
|
|
|
|
|
6 chair seats labeled 1C-6C + central DRAW SEA btn. Click swaps
|
|
|
|
|
data-phase to picker (the picker UX itself lands in iter 2).
|
|
|
|
|
3. Sig + no equipped deck → same landing PLUS a 'Default deck warning'
|
|
|
|
|
Brief banner identical to the one on /billboard/my-sign/ (the user
|
|
|
|
|
is headed for a draw against the Earthman [Shabby Cardstock]
|
|
|
|
|
backup deck unless they equip one first).
|
My Sea applet shell — Sprint 3 of the My Sea roadmap
User roadmap step (Sprint 3 of cluster): scaffold the My Sea applet on the gameboard + the standalone /gameboard/my-sea/ page where later sprints will host the gatekeeper / sig-select / sea-select reskin for solo-user draws. Shell-only — no draw flow yet; latest-draw rendering, mid-progress save, daily quota land in Sprints 4-9 ; **migration**: `applets/migrations/0008_seed_my_sea_applet.py` — RunPython that `update_or_create`s Applet(`slug='my-sea'`, name='My Sea', context='gameboard', default_visible=True, grid_cols=12, grid_rows=4). 12×4 wide horizontal banner so the Celtic Cross spread's 10 cards can render left-to-right in the applet aperture, scrollable like My Palette (per user spec). Reverse migration (`unseed`) deletes the row so the migration is reversible for staging rollbacks ; **applet partial**: `templates/apps/gameboard/_partials/_applet-my-sea.html` — same `{% applet_context %}` auto-discovery shape every other applet uses (`<section id="id_applet_my_sea" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};">`). Header is a `<h2><a href="{% url 'my_sea' %}">My Sea</a></h2>` link (gold via global `body a` rule); body is a `.my-sea-scroll` container that either renders `.my-sea-card` cells from a `latest_draw_cards` context (TBD in Sprint 4-7) or a `.my-sea-empty` placeholder line "No draws yet." for fresh users ; **standalone page**: new `gameboard/views.py:my_sea` view + url at `/gameboard/my-sea/` (URL name `my_sea`) rendering `apps/gameboard/my_sea.html` — `{% extends "core/base.html" %}` shell w. letter-spread `<span>My</span><span>Sea</span>` h2 wordmark + `.my-sea-page__empty` placeholder paragraph "Your sea is calm. Draws will appear here." `page_class` doubled to `page-gameboard page-my-sea` so the body inherits the gameboard's landscape aperture treatment AND any future my-sea-specific styles can target a single class. Login-required like the rest of gameboard ; **tests (+6 ITs)**: GameboardViewTest gains 3 — `test_gameboard_shows_my_sea_applet` (cssselect pins #id_applet_my_sea), `test_my_sea_applet_renders_empty_state_for_new_user` (asserts ".my-sea-empty" text + no ".my-sea-card" rows), `test_my_sea_applet_header_links_to_my_sea_page` (h2 a href == reverse('my_sea')); new MySeaViewTest class — `test_my_sea_requires_login` (redirect to /?next=...), `test_my_sea_renders_200`, `test_my_sea_uses_gameboard_page_class` (page-gameboard + page-my-sea both in body class). Existing GameboardViewTest setUp already does `get_or_create` per-applet so no fixture change needed for the migration-driven my-sea row ; 1005 IT/UT green (+6 from 999) in 45s; visual verified in Claudezilla at iPhone-14 portrait — applet renders w. rotated "MY SEA" vertical label + "No draws yet." body; /gameboard/my-sea/ standalone page renders w. letter-spread wordmark + placeholder ; **next**: Sprint 4 — My Sea sig-select phase (single-significator pick for solo user, w. the parameterized hex CSS from Sprint 1 hosting the chair-less or single-chair variant)
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:45:57 -04:00
|
|
|
"""
|
2026-05-19 01:38:55 -04:00
|
|
|
user_has_sig = request.user.significator_id is not None
|
2026-05-19 15:15:37 -04:00
|
|
|
no_equipped_deck = request.user.equipped_deck_id is None
|
My Sea applet shell — Sprint 3 of the My Sea roadmap
User roadmap step (Sprint 3 of cluster): scaffold the My Sea applet on the gameboard + the standalone /gameboard/my-sea/ page where later sprints will host the gatekeeper / sig-select / sea-select reskin for solo-user draws. Shell-only — no draw flow yet; latest-draw rendering, mid-progress save, daily quota land in Sprints 4-9 ; **migration**: `applets/migrations/0008_seed_my_sea_applet.py` — RunPython that `update_or_create`s Applet(`slug='my-sea'`, name='My Sea', context='gameboard', default_visible=True, grid_cols=12, grid_rows=4). 12×4 wide horizontal banner so the Celtic Cross spread's 10 cards can render left-to-right in the applet aperture, scrollable like My Palette (per user spec). Reverse migration (`unseed`) deletes the row so the migration is reversible for staging rollbacks ; **applet partial**: `templates/apps/gameboard/_partials/_applet-my-sea.html` — same `{% applet_context %}` auto-discovery shape every other applet uses (`<section id="id_applet_my_sea" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};">`). Header is a `<h2><a href="{% url 'my_sea' %}">My Sea</a></h2>` link (gold via global `body a` rule); body is a `.my-sea-scroll` container that either renders `.my-sea-card` cells from a `latest_draw_cards` context (TBD in Sprint 4-7) or a `.my-sea-empty` placeholder line "No draws yet." for fresh users ; **standalone page**: new `gameboard/views.py:my_sea` view + url at `/gameboard/my-sea/` (URL name `my_sea`) rendering `apps/gameboard/my_sea.html` — `{% extends "core/base.html" %}` shell w. letter-spread `<span>My</span><span>Sea</span>` h2 wordmark + `.my-sea-page__empty` placeholder paragraph "Your sea is calm. Draws will appear here." `page_class` doubled to `page-gameboard page-my-sea` so the body inherits the gameboard's landscape aperture treatment AND any future my-sea-specific styles can target a single class. Login-required like the rest of gameboard ; **tests (+6 ITs)**: GameboardViewTest gains 3 — `test_gameboard_shows_my_sea_applet` (cssselect pins #id_applet_my_sea), `test_my_sea_applet_renders_empty_state_for_new_user` (asserts ".my-sea-empty" text + no ".my-sea-card" rows), `test_my_sea_applet_header_links_to_my_sea_page` (h2 a href == reverse('my_sea')); new MySeaViewTest class — `test_my_sea_requires_login` (redirect to /?next=...), `test_my_sea_renders_200`, `test_my_sea_uses_gameboard_page_class` (page-gameboard + page-my-sea both in body class). Existing GameboardViewTest setUp already does `get_or_create` per-applet so no fixture change needed for the migration-driven my-sea row ; 1005 IT/UT green (+6 from 999) in 45s; visual verified in Claudezilla at iPhone-14 portrait — applet renders w. rotated "MY SEA" vertical label + "No draws yet." body; /gameboard/my-sea/ standalone page renders w. letter-spread wordmark + placeholder ; **next**: Sprint 4 — My Sea sig-select phase (single-significator pick for solo user, w. the parameterized hex CSS from Sprint 1 hosting the chair-less or single-chair variant)
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:45:57 -04:00
|
|
|
return render(request, "apps/gameboard/my_sea.html", {
|
2026-05-19 01:38:55 -04:00
|
|
|
"user_has_sig": user_has_sig,
|
2026-05-19 15:15:37 -04:00
|
|
|
"no_equipped_deck": no_equipped_deck,
|
|
|
|
|
"show_backup_intro_banner": user_has_sig and no_equipped_deck,
|
2026-05-19 16:06:14 -04:00
|
|
|
# Sprint 5 iter 2 — significator pinned in `.sea-pos-core` on the
|
|
|
|
|
# picker phase. Template guards on `user_has_sig` so a None pass-
|
|
|
|
|
# through is safe; we pass the FK directly so `.corner_rank` +
|
|
|
|
|
# `.suit_icon` resolve at render time.
|
|
|
|
|
"significator": request.user.significator,
|
|
|
|
|
"significator_reversed": request.user.significator_reversed,
|
My Sea form col + SPREAD dropdown w. 3-card/6-card section dividers — Sprint 5 iter 3 of My Sea roadmap — TDD
Picker phase form col: SPREAD combobox w. 6 spread options under 2 horizontal section dividers ("3-card spreads" / "6-card spreads"), reversal-% caption, GRAVITY + LEVITY deck swatches, LOCK HAND + DEL btns. Default = Situation, Action, Outcome (a 3-card spread). Selecting a 6-card spread (Celtic Cross Waite-Smith or Escape Velocity) swaps `.my-sea-cross[data-spread-shape]` from `three-card` to `six-card` — revealing the crown / lay / cross cells that the default 3-card variants hide.
Naming correction (user-locked): the spread itself is a "three-card spread" not a "three-card cross" — "cross" stays scoped to the Celtic Cross variants (6-card spreads). CSS class `.my-sea-cross` carries grid-container semantics regardless of which spread shape is active; the spread-vs-cross distinction lives at the spread-name layer only.
- **View** (gameboard/views.py): `my_sea` adds `default_spread = "situation-action-outcome"` + `reversals_pct = 25` context keys.
- **Template** (my_sea.html): renders all 7 cross cells (crown/leave/core+cover+cross/loom/lay) unconditionally + adds `data-spread-shape="three-card"` to `.my-sea-cross`. Form col DRY-reuses gameroom `_sea_overlay.html`'s `.sea-form-col` shape — `.sea-form-main` w. `.sea-field` (SPREAD label + reversal hint + custom combobox) + `.sea-stacks` (GRAVITY + LEVITY swatches) + `.sea-form-actions` (LOCK HAND + DEL). 6 options + 2 dividers in the combobox `<ul>`; dividers are `role="presentation"` so `combobox.js` skips them naturally. Inline IIFE listens for the hidden `<input id="id_sea_spread">`'s `change` event + sets `.my-sea-cross`'s `data-spread-shape` based on whether the value is in `['waite-smith', 'escape-velocity']`. No new combobox.js wiring — the existing module's `change`-bubbling contract feeds straight in.
- **SCSS** (_gameboard.scss):
- `.my-sea-cross[data-spread-shape="three-card"]` — single-row `"leave core loom"` grid + `display: none` on crown/lay/cross.
- `.my-sea-cross[data-spread-shape="six-card"]` — inherits the gameroom `.sea-cross`'s 3×3 grid + reveals all cells.
- `.sea-select-divider` — section header style mirrors `.kit-bag-label`'s small-uppercase-underlined-letter-spaced --quaUser/0.75 treatment but HORIZONTAL (kit-bag uses `writing-mode: vertical-rl`; dropdown menus are flat). `pointer-events: none` belt-and-braces against accidental click/hover.
- `.my-sea-form-col` — width-constrains the form col so the picker's cross + form sit side-by-side.
**Iter-2 contract updated** (cells in DOM, hidden via CSS for 3-card default):
- FT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_hides_six_card_only_positions_by_default` — asserts the 3 cells are in the DOM but `is_displayed() == False` so iter-3's spread switch can reveal them via CSS without re-rendering.
- IT `test_picker_does_not_render_forsaken_positions` → renamed to `test_picker_renders_six_card_only_positions_for_spread_switch` — assertContains the classes (server now renders them unconditionally).
**Tests**:
- 4 FTs in new `MySeaSpreadFormTest`: combobox renders 6 options + 2 dividers w. correct labels, default is Situation/Action/Outcome (hidden input value + visible current-label span + cross's data-spread-shape), picking Celtic Cross flips data-spread-shape to six-card + reveals crown/lay/cross, form col carries DECKS swatches + LOCK HAND + DEL + reversal-% caption. Combobox `<li>` options are inside `aria-expanded='false'` listbox → use `get_attribute("textContent")` not `.text` (which returns "" for Selenium-hidden elements).
- 7 ITs in new `MySeaSpreadFormTemplateTest`: default_spread + reversals_pct context keys, all 6 options + both labels render, 2 dividers render w. expected text, default option carries aria-selected="true", cross's initial data-spread-shape="three-card", form col DECKS + buttons + reversal hint render.
Tests: 32/32 FT green across test_bill_my_sign + test_game_my_sea; 1048/1048 IT/UT green in 52s.
Card-draw mechanics (clicking a deck swatch deposits a card into the next empty slot; LOCK HAND commits the draw) defer to iter 4 — this iter ships the spread-selection + layout-shape switch UI; the buttons are stubs (LOCK HAND starts disabled, DEL is a placeholder).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:23:25 -04:00
|
|
|
# Sprint 5 iter 3 — SPREAD dropdown defaults to Situation/Action/
|
|
|
|
|
# Outcome (a 3-card spread) per user-locked spec; `reversals_pct`
|
|
|
|
|
# is a placeholder UI value pending the per-user setting.
|
|
|
|
|
"default_spread": "situation-action-outcome",
|
|
|
|
|
"reversals_pct": 25,
|
My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD
Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b.
**Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock:
- No `room` context; excludes only the **current user's significator** (no other seated gamers).
- Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working.
- Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.)
Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL).
`.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there.
**FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP):
- deck JSON embedded w. two polarity halves, disjoint card ids;
- user significator excluded from both halves;
- first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside;
- second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected;
- 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops;
- DEL resets every filled slot, LOCK HAND re-disables;
- LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks;
- switching to MBS mid-draw wipes the in-progress hand.
**ITs** (6 in new `MySeaDeckDataViewTest`):
- context `sea_deck_data` has `levity` + `gravity` keys, both lists;
- user significator absent from both halves;
- halves are disjoint sets of card ids;
- card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract;
- template embeds via `<script id="id_my_sea_deck" type="application/json">`;
- no-equipped-deck users get the Earthman backup pile (not empty).
Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s.
**Deferred to iter 4b** (server persistence):
- `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at);
- LOCK HAND POST endpoint → commits the hand to the DB;
- 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap;
- Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:02:20 -04:00
|
|
|
# Sprint 5 iter 4a — shuffled deck (levity + gravity halves, sig
|
|
|
|
|
# excluded) for the client-side card-draw mechanic. Embedded in
|
|
|
|
|
# the template via `{{ sea_deck_data|json_script:"..." }}`; JS
|
|
|
|
|
# reads on init + maintains the in-progress hand state client-
|
|
|
|
|
# side. Persistence (LOCK HAND → POST) lands in iter 4b.
|
|
|
|
|
"sea_deck_data": _my_sea_deck_data(request.user) if user_has_sig else {"levity": [], "gravity": []},
|
My Sea applet shell — Sprint 3 of the My Sea roadmap
User roadmap step (Sprint 3 of cluster): scaffold the My Sea applet on the gameboard + the standalone /gameboard/my-sea/ page where later sprints will host the gatekeeper / sig-select / sea-select reskin for solo-user draws. Shell-only — no draw flow yet; latest-draw rendering, mid-progress save, daily quota land in Sprints 4-9 ; **migration**: `applets/migrations/0008_seed_my_sea_applet.py` — RunPython that `update_or_create`s Applet(`slug='my-sea'`, name='My Sea', context='gameboard', default_visible=True, grid_cols=12, grid_rows=4). 12×4 wide horizontal banner so the Celtic Cross spread's 10 cards can render left-to-right in the applet aperture, scrollable like My Palette (per user spec). Reverse migration (`unseed`) deletes the row so the migration is reversible for staging rollbacks ; **applet partial**: `templates/apps/gameboard/_partials/_applet-my-sea.html` — same `{% applet_context %}` auto-discovery shape every other applet uses (`<section id="id_applet_my_sea" style="--applet-cols: {{ entry.applet.grid_cols }}; --applet-rows: {{ entry.applet.grid_rows }};">`). Header is a `<h2><a href="{% url 'my_sea' %}">My Sea</a></h2>` link (gold via global `body a` rule); body is a `.my-sea-scroll` container that either renders `.my-sea-card` cells from a `latest_draw_cards` context (TBD in Sprint 4-7) or a `.my-sea-empty` placeholder line "No draws yet." for fresh users ; **standalone page**: new `gameboard/views.py:my_sea` view + url at `/gameboard/my-sea/` (URL name `my_sea`) rendering `apps/gameboard/my_sea.html` — `{% extends "core/base.html" %}` shell w. letter-spread `<span>My</span><span>Sea</span>` h2 wordmark + `.my-sea-page__empty` placeholder paragraph "Your sea is calm. Draws will appear here." `page_class` doubled to `page-gameboard page-my-sea` so the body inherits the gameboard's landscape aperture treatment AND any future my-sea-specific styles can target a single class. Login-required like the rest of gameboard ; **tests (+6 ITs)**: GameboardViewTest gains 3 — `test_gameboard_shows_my_sea_applet` (cssselect pins #id_applet_my_sea), `test_my_sea_applet_renders_empty_state_for_new_user` (asserts ".my-sea-empty" text + no ".my-sea-card" rows), `test_my_sea_applet_header_links_to_my_sea_page` (h2 a href == reverse('my_sea')); new MySeaViewTest class — `test_my_sea_requires_login` (redirect to /?next=...), `test_my_sea_renders_200`, `test_my_sea_uses_gameboard_page_class` (page-gameboard + page-my-sea both in body class). Existing GameboardViewTest setUp already does `get_or_create` per-applet so no fixture change needed for the migration-driven my-sea row ; 1005 IT/UT green (+6 from 999) in 45s; visual verified in Claudezilla at iPhone-14 portrait — applet renders w. rotated "MY SEA" vertical label + "No draws yet." body; /gameboard/my-sea/ standalone page renders w. letter-spread wordmark + placeholder ; **next**: Sprint 4 — My Sea sig-select phase (single-significator pick for solo user, w. the parameterized hex CSS from Sprint 1 hosting the chair-less or single-chair variant)
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 19:45:57 -04:00
|
|
|
"page_class": "page-gameboard page-my-sea",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
My Sea client-side card draw + DEL + LOCK HAND visual lock — Sprint 5 iter 4a of My Sea roadmap — TDD
Two-step deposit flow lifted from gameroom sea.js's `_fillSlot`: click a polarity stack → FLIP btn appears on the active stack → click FLIP → top card pops from that polarity's pile and deposits into `DRAW_ORDER[currentSpread][_filled]`. Per-spread hand-size completion (3 for any three-card spread, 6 for Celtic Cross variants) flips LOCK HAND from disabled to enabled. DEL fully resets — every filled slot reverts to `.sea-card-slot--empty` w. its `.sea-pos-label` re-rendered, piles re-clone from the immutable server payload, LOCK HAND re-disables. Switching spreads mid-draw triggers the same reset (position-subset + draw-order both change). LOCK HAND click visually locks the picker (`.my-sea-picker--locked` + `.btn-disabled` on stacks/DEL/itself) — server persistence defers to iter 4b.
**Card source**: new `_my_sea_deck_data(user)` helper in `apps/gameboard/views.py` mirrors the gameroom `epic.views.sea_deck` JSON contract — same `_card_dict` shape (id/name/arcana/suit/number/corner_rank/suit_icon/name_group/name_title/qualifiers/reversed). Differences from the room version per spec lock:
- No `room` context; excludes only the **current user's significator** (no other seated gamers).
- Backup-deck fallthrough: `user.equipped_deck or DeckVariant.filter(slug='earthman').first()` — mirrors `personal_sig_cards`, keeps the no-deck-equipped path working.
- Reversal probability hardcoded at 0.25 per the iter 3 spec lock. (Future per-user config will share a helper w. the gameroom's `stack_reversal_probability`.)
Deck data flows in via `{{ sea_deck_data|json_script:"id_my_sea_deck" }}` — Django's built-in script-tag JSON embedder. JS reads `id_my_sea_deck`'s textContent on init + maintains `_levityPile` / `_gravityPile` working copies that shift one card per deposit. DEL re-clones from the immutable initial payload rather than re-fetching (server is stateless wrt this client-side dealing — same shuffle survives DEL).
`.my-sea-picker--locked` SCSS: `.sea-deck-stack.btn-disabled` gets `pointer-events: none; opacity: 0.5` per [[feedback_btn_disabled_pointer_events]] convention. Hand state freezes; only iter 4b's LOCK HAND POST can mutate the persisted state from there.
**FTs** (9 in new `MySeaCardDrawTest`, using a `_draw_one(picker, polarity)` helper that clicks the stack + waits for the FLIP btn to surface + clicks FLIP):
- deck JSON embedded w. two polarity halves, disjoint card ids;
- user significator excluded from both halves;
- first LEVITY draw lands in SAO's first slot (`.sea-pos-lay`) w. `.sea-card-slot--filled.sea-card-slot--levity` + corner_rank inside;
- second draw (GRAVITY) lands in SAO's second slot (`.sea-pos-cover`) w. polarity reflected;
- 3 draws complete the SAO hand → LOCK HAND `disabled` attribute drops;
- DEL resets every filled slot, LOCK HAND re-disables;
- LOCK HAND click adds `.my-sea-picker--locked` + `.btn-disabled` on the stacks;
- switching to MBS mid-draw wipes the in-progress hand.
**ITs** (6 in new `MySeaDeckDataViewTest`):
- context `sea_deck_data` has `levity` + `gravity` keys, both lists;
- user significator absent from both halves;
- halves are disjoint sets of card ids;
- card dicts carry `id` / `corner_rank` / `suit_icon` / `reversed` (bool); shape matches gameroom contract;
- template embeds via `<script id="id_my_sea_deck" type="application/json">`;
- no-equipped-deck users get the Earthman backup pile (not empty).
Tests: 41/41 FT green across test_bill_my_sign + test_game_my_sea; 1055/1055 IT/UT green in 53s.
**Deferred to iter 4b** (server persistence):
- `MySeaDraw` model (FK to user, spread name, JSON field for hand layout, created_at);
- LOCK HAND POST endpoint → commits the hand to the DB;
- 1/24h FREE DRAW quota check + the eventual FREE DRAW → DRAW SEA btn-label swap;
- Sig stage card full populate (name/qualifier/keywords/FYI/SPIN/FLIP) — currently corner rank + suit icon only.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:02:20 -04:00
|
|
|
def _my_sea_deck_data(user):
|
|
|
|
|
"""Build the shuffled deck (levity + gravity halves) for the my-sea
|
|
|
|
|
picker's card-draw mechanic. Mirrors the gameroom `epic.views.sea_
|
|
|
|
|
deck` endpoint's card_dict shape so iter 4b's render/persist path
|
|
|
|
|
can reuse the same JSON contract.
|
|
|
|
|
|
|
|
|
|
Differences from the room version:
|
|
|
|
|
- No `room` context — exclude only the current user's significator
|
|
|
|
|
(no other seated gamers to worry about).
|
|
|
|
|
- Backup-deck fallthrough: if the user's `equipped_deck` is None,
|
|
|
|
|
fall back to Earthman (mirrors `personal_sig_cards`).
|
|
|
|
|
- Reversal probability hardcoded to 0.25 per the iter 3 spec lock;
|
|
|
|
|
future per-user config rides on the shared `stack_reversal_
|
|
|
|
|
probability` helper.
|
|
|
|
|
"""
|
|
|
|
|
import random
|
|
|
|
|
from apps.epic.models import DeckVariant, TarotCard
|
|
|
|
|
deck = user.equipped_deck or DeckVariant.objects.filter(slug="earthman").first()
|
|
|
|
|
if not deck:
|
|
|
|
|
return {"levity": [], "gravity": []}
|
|
|
|
|
available = list(TarotCard.objects.filter(deck_variant=deck))
|
|
|
|
|
if user.significator_id:
|
|
|
|
|
available = [c for c in available if c.id != user.significator_id]
|
|
|
|
|
random.shuffle(available)
|
|
|
|
|
mid = len(available) // 2
|
|
|
|
|
reversal_prob = 0.25
|
|
|
|
|
|
|
|
|
|
def _card_dict(c):
|
|
|
|
|
return {
|
|
|
|
|
"id": c.id,
|
|
|
|
|
"name": c.name,
|
|
|
|
|
"arcana": c.arcana,
|
|
|
|
|
"suit": c.suit,
|
|
|
|
|
"number": c.number,
|
|
|
|
|
"corner_rank": c.corner_rank,
|
|
|
|
|
"suit_icon": c.suit_icon,
|
|
|
|
|
"name_group": c.name_group,
|
|
|
|
|
"name_title": c.name_title,
|
|
|
|
|
"levity_qualifier": c.levity_qualifier,
|
|
|
|
|
"gravity_qualifier": c.gravity_qualifier,
|
|
|
|
|
"reversal_qualifier": c.reversal_qualifier,
|
|
|
|
|
"reversed": random.random() < reversal_prob,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"levity": [_card_dict(c) for c in available[:mid]],
|
|
|
|
|
"gravity": [_card_dict(c) for c in available[mid:]],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-03-24 22:57:12 -04:00
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def tarot_fan(request, deck_id):
|
|
|
|
|
from apps.epic.models import TarotCard
|
|
|
|
|
deck = get_object_or_404(DeckVariant, pk=deck_id)
|
|
|
|
|
if not request.user.unlocked_decks.filter(pk=deck_id).exists():
|
|
|
|
|
return HttpResponse(status=403)
|
2026-04-27 20:48:12 -04:00
|
|
|
_suit_order = {"BRANDS": 0, "GRAILS": 1, "BLADES": 2, "CROWNS": 3,
|
|
|
|
|
"WANDS": 0, "CUPS": 1, "SWORDS": 2, "PENTACLES": 3}
|
2026-03-25 00:24:26 -04:00
|
|
|
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),
|
|
|
|
|
)
|
2026-03-24 22:57:12 -04:00
|
|
|
return render(request, "apps/gameboard/_partials/_tarot_fan.html", {
|
|
|
|
|
"deck": deck,
|
|
|
|
|
"cards": cards,
|
|
|
|
|
})
|