diff --git a/README.md b/README.md index 16c2f6db..f8b67d7f 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,14 @@ docker compose -f docker-compose.yml -f docker-compose.stack.yml up -d --build - Site on http://localhost:8080 (`WEB_PORT` to change it), Keycloak users `alice` / `bob` (password `TestPassword1234`), decks API on http://localhost:8001. Playwright: `cd tests/e2e && npm ci && npx playwright test`. CI runs the same on every pull request (`.github/workflows/plugins-ci.yml`). Loading checks (skeletons, layout shifts): `npm run loading` and `npm run loading:timeline -- ` in `tests/e2e` (`AGENTS.md` § Checks). +**Alt arts with the real ownership service.** The stack's digital ownership is a mock (`docker/stack/ownership-mock`). To try the default alt arts with [AlteredOwnership](https://github.com/Altered-Re-Union/AlteredOwnership) itself, built from GitHub (alice and bob own Axiom alt arts and have default alt arts; alice is in « Global » mode, with a deck of plain prints that takes her default alt arts): + +```bash +docker compose -f docker-compose.yml -f docker-compose.stack.yml -f docker-compose.ownership.yml up -d --build --wait +``` + +Sign in again after its first start (the service needs the `read-collection` scope in the site's tokens). Details in `docker-compose.ownership.yml` and `docker/stack/ownership-real/`. + **Like production.** To get the plugins active on https://altered.re and its public content (news, content pages, side menu, footer, fonts, logo, home page text, images): ```bash diff --git a/api/v1/services/proxy.php b/api/v1/services/proxy.php index b54ceaa0..26a0a1cf 100644 --- a/api/v1/services/proxy.php +++ b/api/v1/services/proxy.php @@ -117,6 +117,11 @@ function serviceProxySend(string $method, string $url, array $headers, string $b if ($res === null) serviceProxyReply(502, ['error' => 'service_unreachable', 'service' => $service]); [$status, $type, $out] = $res; +// A deck deleted: what the site keeps about it goes too (includes/func.alt-arts.php). +if ($service === 'decks' && $method === 'DELETE' && $status >= 200 && $status < 300 && preg_match('#^api/decks/([A-Za-z0-9-]{1,64})$#', $path, $m)) { + require_once dirname(__DIR__, 3) . '/includes/func.alt-arts.php'; + altArtForgetDeck($userId, $m[1]); +} require_once dirname(__DIR__, 3) . '/includes/json-gzip.php'; jsonGzipStart(); http_response_code($status); diff --git a/design-system/css/components/key.css b/design-system/css/components/key.css new file mode 100644 index 00000000..6b3b4630 --- /dev/null +++ b/design-system/css/components/key.css @@ -0,0 +1,100 @@ +/* + * Keys — a row of toggle buttons drawn as keys: raised when off, pressed down and filled when on, flat and faded + * when disabled. + * Used for the 1st, 2nd and 3rd card that take an illustration (alt arts), one row above each illustration. + *
+ * + * … + *
+ * Docs: design-system/docs/components/key.md + */ +.ac-keys { + display: grid; + grid-template-columns: repeat(var(--ac-keys-count, 3), minmax(0, 1fr)); + gap: var(--ac-space-1-5); +} +.ac-key { + --ac-key-height: var(--ac-control-lg); + --ac-key-border: 1.5px; + --ac-key-edge: 4px; + box-sizing: border-box; + height: var(--ac-key-height); + min-width: 0; + margin: 0; + padding: 0 var(--ac-space-1); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--ac-space-0-5); + border: var(--ac-key-border) solid var(--ac-color-border-control); + border-bottom: var(--ac-key-edge) solid var(--ac-color-border-dashed); + border-radius: var(--ac-radius-control); + background: var(--ac-color-surface); + color: var(--ac-color-text-2); + font-family: var(--ac-font-family); + cursor: pointer; + transition: + opacity var(--ac-duration-fast), + color var(--ac-duration-fast), + border-color var(--ac-duration-fast), + transform var(--ac-duration-fast); +} +.ac-key--sm { + --ac-key-height: var(--ac-control-md); +} +/* Pressed: the key goes down by its edge, filled. */ +.ac-key[aria-pressed='true'] { + height: calc(var(--ac-key-height) - var(--ac-key-edge) + var(--ac-key-border)); + margin-top: calc(var(--ac-key-edge) - var(--ac-key-border)); + border-color: var(--ac-color-primary-strong); + border-bottom-width: var(--ac-key-border); + background: var(--ac-color-primary); + color: var(--ac-color-on-primary); + box-shadow: inset 0 2px 4px var(--ac-color-overlay-label); + opacity: 1; + cursor: default; +} +.ac-key:not([aria-pressed='true']):not(:disabled):active { + transform: translateY(2px); +} +/* Disabled: flat (no edge), no fill, faded: it cannot be pressed. */ +.ac-key:disabled:not([aria-pressed='true']) { + border-bottom-width: var(--ac-key-border); + border-color: var(--ac-color-border); + background: transparent; + color: var(--ac-color-text-disabled); + opacity: 0.6; + cursor: not-allowed; +} +.ac-key:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: 2px; + opacity: 1; +} +@media (hover: hover) { + .ac-key:not([aria-pressed='true']):not(:disabled):hover { + border-color: var(--ac-color-primary); + color: var(--ac-color-primary); + opacity: 1; + transform: translateY(-1px); + } +} +.ac-key__num { + font-size: var(--ac-font-size-body-strong); + font-weight: 800; + line-height: 1; +} +.ac-key__num sup { + font-size: 0.62em; + line-height: 0; +} +.ac-key__word { + font: var(--ac-font-micro); +} +/* A single key with a word only (« Choisir » / « Choisie »). */ +.ac-key__label { + font: var(--ac-font-body-strong); +} diff --git a/design-system/docs/components/README.md b/design-system/docs/components/README.md index 5278d262..f887c2fb 100644 --- a/design-system/docs/components/README.md +++ b/design-system/docs/components/README.md @@ -11,6 +11,7 @@ One page per component family. Each page matches one file of `css/components/` ( | [field.md](field.md) | `field.css` | Field wrapper, label, hint, error, input, textarea, input with icon, select, checkbox / radio, radio card, file input, switch | | [listbox.md](listbox.md) | `listbox.css`, `js/ac.js` | Select drawn by the design system: search, groups, colour dots | | [segmented.md](segmented.md) | `segmented.css` | Segmented control | +| [key.md](key.md) | `key.css` | Keys: toggle buttons raised when off, pressed down when on (alt-art cards) | | [chips.md](chips.md) | `chips.css` | Chip, badge, tag, count, avatar | | [card.md](card.md) | `card.css` | Card, list, empty state, collapsible | | [drawer.md](drawer.md) | `drawer.css` | Drawer handle and tab: hide and show a side panel | diff --git a/design-system/docs/components/key.md b/design-system/docs/components/key.md new file mode 100644 index 00000000..6bc0725d --- /dev/null +++ b/design-system/docs/components/key.md @@ -0,0 +1,57 @@ +# Keys + +`css/components/key.css`. + +A row of toggle buttons drawn as keys. A key that is off is raised (thick bottom edge); a key that +is on is pressed down and filled in primary; a disabled key is flat and faded. Several keys of a row +can be on at once. + +Used for alt arts: above each illustration of a card, its 1st, 2nd and 3rd card. A key on is a +card that takes this illustration; pressing another key gives it that card. A token has a single +key (« Choisir » / « Choisie »). + +Use a [segmented control](segmented.md) instead for one choice among a few options, and a +[switch](field.md) for a setting that is on or off. + +## Markup + +```php +
+ + + +
+ +
+ +
+``` + +| Class / attribute | Role | +|---|---| +| `ac-keys` | Grid of equal keys, `--ac-space-1-5` apart. `--ac-keys-count` sets the number of columns (3 by default). | +| `ac-key` | A key, `--ac-control-lg` tall (52 px in touch density), raised by a 4 px bottom edge in `--ac-color-border-dashed`, text in `--ac-color-text-2`. | +| `ac-key--sm` | Key at `--ac-control-md` (lists with many rows). | +| `aria-pressed="true"` | Pressed down by its edge, `--ac-color-primary` with an inner shadow, full opacity. | +| `:disabled` | Flat (no edge), no fill, `--ac-color-border` and `--ac-color-text-disabled` at 60 % opacity: clearly apart from a key that can be pressed. | +| `ac-key__num` | Number, `--ac-font-size-body-strong`, its `` (ordinal suffix) at 0.62 em. | +| `ac-key__word` | Word under the number (« carte »), `--ac-font-micro`. | +| `ac-key__label` | Single word instead of number and word (« Choisir »), `--ac-font-body-strong`. | + +Hover (pointer devices only): border and text in primary, the key rises by 1 px. +A pressed key goes down by 2 px while the button is held. + +## Accessibility + +- Real ` + + + +
+ + + +
+
+ +
+ + + +

Cards, lists, empty state

diff --git a/plugins/core-altered-cards/includes/functions.php b/plugins/core-altered-cards/includes/functions.php index 250be444..d6213ec4 100644 --- a/plugins/core-altered-cards/includes/functions.php +++ b/plugins/core-altered-cards/includes/functions.php @@ -36,6 +36,29 @@ function deckApiToken(): ?string { return $token ?: null; } +/** + * Deletes a deck of the signed-in player from the decks API: the HTTP status (0 when unreachable). On a 2xx, what the + * site keeps about the deck goes too (altArtForgetDeck). + */ +function cacDeckApiDelete(string $deckId, string $token): int +{ + $ch = curl_init(DECKS_API_URL . '/api/decks/' . rawurlencode($deckId)); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Accept: application/json'], + CURLOPT_TIMEOUT => 15, + ]); + curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code >= 200 && $code < 300) { + require_once dirname(__DIR__, 3) . '/includes/func.alt-arts.php'; + altArtForgetDeck((int)($_SESSION['user_id'] ?? 0), $deckId); + } + return $code; +} + /** @return array{upvoteCount: int, hasUpvoted: bool}|null */ function cacDeckApiUpvote(string $deckId, string $token): ?array { @@ -314,36 +337,11 @@ function cacHeroPortraitRef(string $ref): string { * same failure mode as the rest of this plugin's ownership calls. */ function cacApplyAltArtPreferencesToCards(array $deckCards, int $userId): array { - if (!defined('OWNERSHIP_API_URL') || !OWNERSHIP_API_URL || $userId <= 0) return $deckCards; - - $checkItems = []; - foreach ($deckCards as $c) { - if (empty($c['cardReference'])) continue; - $checkItems[] = ['reference' => (string)$c['cardReference'], 'quantity' => (int)($c['quantity'] ?? 1)]; - } - if (!$checkItems) return $deckCards; - - $result = collApiRequest(rtrim(OWNERSHIP_API_URL, '/'), 'POST', '/api/alt-arts/apply-to-deck', $userId, $checkItems); - if (!is_array($result) || !isset($result['lines']) || !is_array($result['lines'])) return $deckCards; - - // Lines[i] is itself a list (a shortfall/preference spread can turn one input line - // into several) — flatten, then merge references that ended up repeated across - // different input lines (e.g. two printings of the same card both falling back to - // the same default art). - $merged = []; - foreach ($result['lines'] as $lines) { - foreach ((array)$lines as $line) { - if (!isset($line['reference'], $line['quantity'])) continue; - $ref = (string)$line['reference']; - $merged[$ref] = ($merged[$ref] ?? 0) + (int)$line['quantity']; - } - } - - $out = []; - foreach ($merged as $ref => $qty) { - $out[] = ['cardReference' => $ref, 'quantity' => $qty]; - } - return $out; + if ($userId <= 0) return $deckCards; + require_once dirname(__DIR__, 3) . '/includes/func.keycloak.php'; + require_once dirname(__DIR__, 3) . '/includes/func.alt-arts.php'; + $token = kc_get_access_token($userId); + return ($token ? altArtApplyToDeck($token, $deckCards) : null) ?: $deckCards; } /** diff --git a/plugins/core-altered-cards/pages/deck.php b/plugins/core-altered-cards/pages/deck.php index 05c96077..8b16fff3 100644 --- a/plugins/core-altered-cards/pages/deck.php +++ b/plugins/core-altered-cards/pages/deck.php @@ -183,16 +183,7 @@ if ($delId) { $token = deckApiToken(); if ($token) { - $ch = curl_init(DECKS_API_URL . '/api/decks/' . rawurlencode($delId)); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CUSTOMREQUEST => 'DELETE', - CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Accept: application/json'], - CURLOPT_TIMEOUT => 15, - ]); - curl_exec($ch); - $delCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + $delCode = cacDeckApiDelete($delId, $token); if ($delCode >= 200 && $delCode < 300) { flash($txt['deleted_ok']); } else { @@ -218,14 +209,7 @@ } $action = $_POST['action'] ?? ''; if ($action === 'delete') { - $ch = curl_init(DECKS_API_URL . '/api/decks/' . rawurlencode($deckId)); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CUSTOMREQUEST => 'DELETE', - CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Accept: application/json'], - CURLOPT_TIMEOUT => 15, - ]); - $r = curl_exec($ch); $c = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + $c = cacDeckApiDelete((string)$deckId, $token); if ($c >= 200 && $c < 300) { echo json_encode(['ok' => true]); } else { diff --git a/plugins/core-altered-cards/pages/decks.php b/plugins/core-altered-cards/pages/decks.php index 2c9e9029..e1b308f5 100644 --- a/plugins/core-altered-cards/pages/decks.php +++ b/plugins/core-altered-cards/pages/decks.php @@ -275,16 +275,7 @@ if ($deleteId) { $token = deckApiToken(); if ($token) { - $ch = curl_init(DECKS_API_URL . '/api/decks/' . rawurlencode($deleteId)); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CUSTOMREQUEST => 'DELETE', - CURLOPT_HTTPHEADER => ['Accept: application/json', 'Authorization: Bearer ' . $token], - CURLOPT_TIMEOUT => 10, - ]); - curl_exec($ch); - $deleteCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + $deleteCode = cacDeckApiDelete($deleteId, $token); if ($deleteCode >= 200 && $deleteCode < 300) { flash($txt['deleted_ok']); } else { diff --git a/plugins/ownership/api/alt-art-leave-global.php b/plugins/ownership/api/alt-art-leave-global.php new file mode 100644 index 00000000..f659800d --- /dev/null +++ b/plugins/ownership/api/alt-art-leave-global.php @@ -0,0 +1,64 @@ +", …]} +// POST /papi/ownership/alt-art-leave-global {"deck": ""} → {"changed": bool} +// « Global » → « par deck » (pages/alt-arts.php): the page lists the player's decks, writes the defaults in each one +// (what Board Game Arena showed of it, see includes/func.alt-arts.php) and drops its brush choices, then sets the mode +// (api/alt-art-preference-mode.php) even when some decks could not be written: best effort. +// A deck is only written while the player is in « Global » (409 otherwise). Manifest: "auth": "user". +require_once __DIR__ . '/../includes/functions.php'; +require_once dirname(__DIR__, 3) . '/includes/func.alt-arts.php'; +require_once dirname(__DIR__, 3) . '/includes/func.keycloak.php'; + +header('Content-Type: application/json; charset=UTF-8'); +header('Cache-Control: private, no-store'); + +if (!defined('OWNERSHIP_API_URL') || !OWNERSHIP_API_URL) { + http_response_code(503); + echo json_encode(['error' => 'ownership']); + return; +} + +$userId = (int)($_SESSION['user_id'] ?? 0); +$token = kc_get_access_token($userId); +if (!$token) { + http_response_code(401); + echo json_encode(['error' => 'token']); + return; +} +// The page sends four decks at a time: the session's lock would run them one after the other. +session_write_close(); + +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') { + $ids = altArtUserDeckIds($token); + if ($ids === null) { + http_response_code(502); + echo json_encode(['error' => 'decks']); + return; + } + echo json_encode(['decks' => $ids]); + return; +} + +$deckId = pluginApiBody()['deck'] ?? ''; +if (!is_string($deckId) || !preg_match('/^[A-Za-z0-9-]{1,64}$/', $deckId)) { + http_response_code(400); + echo json_encode(['error' => 'deck']); + return; +} + +// Read from the service, not the session's cache: outside « Global », apply-to-deck would only swap the prints not owned. +[, $mode] = altArtHttp('GET', rtrim(OWNERSHIP_API_URL, '/') . '/api/alt-arts/preference-mode', $token); +if (($mode['mode'] ?? null) !== 'Global') { + http_response_code(409); + echo json_encode(['error' => 'mode']); + return; +} + +$changed = altArtWriteDefaultsInDeck($token, $deckId); +if ($changed === null) { + http_response_code(502); + echo json_encode(['error' => 'write']); + return; +} +altArtForgetDeck($userId, $deckId); +echo json_encode(['changed' => $changed]); diff --git a/plugins/ownership/api/alt-art-preference-mode.php b/plugins/ownership/api/alt-art-preference-mode.php index 39725e6d..c13b085e 100644 --- a/plugins/ownership/api/alt-art-preference-mode.php +++ b/plugins/ownership/api/alt-art-preference-mode.php @@ -1,8 +1,9 @@ 'Unauthorized']); - exit; -} - $userId = (int)($_SESSION['user_id'] ?? 0); -$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; -if ($method === 'GET') { +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') { echo json_encode(['mode' => ownGetAltArtPreferenceMode($userId)]); exit; } -if ($method !== 'PUT') { - http_response_code(405); - echo json_encode(['error' => 'Method not allowed']); - exit; -} - -$body = json_decode(file_get_contents('php://input'), true); -if (!is_array($body)) $body = []; - -if (!csrfValid($body['csrf_token'] ?? null)) { - http_response_code(403); - echo json_encode(['error' => 'Invalid CSRF token']); - exit; -} - -$mode = $body['mode'] ?? null; +$mode = pluginApiBody()['mode'] ?? null; if (!in_array($mode, ['PerDeck', 'Global'], true)) { http_response_code(400); echo json_encode(['error' => 'mode must be "PerDeck" or "Global"']); exit; } -[$status, $raw] = ownApiRequestRaw('PUT', '/api/alt-arts/preference-mode', $userId, ['mode' => $mode]); - +[$status] = ownApiRequestRaw('PUT', '/api/alt-arts/preference-mode', $userId, ['mode' => $mode]); if ($status !== 204) { http_response_code($status ?: 502); echo json_encode(['error' => 'Ownership API error']); diff --git a/plugins/ownership/api/alt-art-search.php b/plugins/ownership/api/alt-art-search.php index d5b077b7..d1aa6a01 100644 --- a/plugins/ownership/api/alt-art-search.php +++ b/plugins/ownership/api/alt-art-search.php @@ -39,17 +39,6 @@ if (is_string($ty) && $ty !== '') $types[] = $ty; } -// PerDeck mode: this page only manages token illustrations — regular card alt-arts are -// chosen per deck in the deckbuilder instead (see AltArtPreferenceMode). Enforced here, -// not just by which filter buttons pages/alt-arts.php renders, so a caller can't bypass -// it by requesting a non-token type directly. -if (!ownIsAltArtGlobalMode($userId)) { - $types = array_values(array_filter($types, fn($ty) => strpos($ty, 'TOKEN') === 0)); - if (!$types) { - $types = ['TOKEN', 'TOKEN_LANDMARK_PERMANENT', 'TOKEN_MANA']; - } -} - foreach ($types as $ty) { $parts[] = 'type[]=' . rawurlencode($ty); } diff --git a/plugins/ownership/assets/style.css b/plugins/ownership/assets/style.css index 7a7c75de..dcfa23a0 100644 --- a/plugins/ownership/assets/style.css +++ b/plugins/ownership/assets/style.css @@ -336,95 +336,288 @@ --ac-icon-size: 48px; } -/* Alt Arts BGA — one row per multi-art family, tiles instead of a paginated grid. - .filter-row/.filter-toggle/.card-altered come from core-altered-cards/assets/style.css, - linked explicitly by pages/alt-arts.php since this plugin doesn't load it by default. */ -/* 4 tiles per row on desktop, 2 on mobile — a family's strip is its own little grid, - wrapping to as many rows as it needs; the
after it (see js/alt-arts.js) is what - visually separates one family from the next. */ -.own-aa-strip { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: var(--ac-space-2-5); - margin-bottom: var(--ac-space-1-5); +/* Default alt arts (pages/alt-arts.php): one row per multi-art family, its name and what decks take on the + left (its cards fanned out), its illustrations under their keys on the right. */ +.own-aa-list { + background: var(--ac-color-surface); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-card); + overflow: hidden; } -/* Wide screens: tiles keep the size they had in the 1200px layout (4 across 1176px of - content) and the strip fits as many as the row allows — auto-fill rather than auto-fit - so every family's tiles stay the same size, even a 2-art family. */ -@media (min-width: 1500px) { - .own-aa-strip { - grid-template-columns: repeat(auto-fill, minmax(calc(1176px / 4 - var(--ac-space-2-5)), 1fr)); +.own-aa-list:empty { + display: none; +} +.own-aa-family { + --chevron: 44px; + display: flex; + flex-wrap: wrap; + align-items: stretch; + gap: var(--ac-space-4) 0; + padding: var(--ac-space-5) var(--ac-space-6); + border-bottom: 1px solid var(--ac-color-divider); +} +.own-aa-family:last-child { + border-bottom: 0; +} +.own-aa-family__head { + flex: 1 1 100%; + display: flex; + flex-direction: column; + gap: var(--ac-space-1); +} +/* As Re:Builder's brush: the choices on a band ending in a chevron, which fits into the notch of what decks take + (centred on its right); on a phone, one under the other, the chevron pointing down. */ +.own-aa-family > .own-aa-row, +.own-aa-family__info { + position: relative; + isolation: isolate; + box-sizing: border-box; +} +.own-aa-family > .own-aa-row::before, +.own-aa-family__info::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; +} +.own-aa-family > .own-aa-row { + padding: var(--ac-space-4) calc(var(--chevron) + var(--ac-space-5)) var(--ac-space-4) var(--ac-space-5); +} +.own-aa-family > .own-aa-row::before { + border-radius: var(--ac-radius-card) 0 0 var(--ac-radius-card); + background: var(--ac-color-primary-tint); + clip-path: polygon(0 0, calc(100% - var(--chevron)) 0, 100% 50%, calc(100% - var(--chevron)) 100%, 0 100%); +} +.own-aa-family__info { + flex: 0 1 330px; + margin-left: calc(var(--ac-space-4) - var(--chevron)); + padding: var(--ac-space-4) var(--ac-space-5) var(--ac-space-4) calc(var(--chevron) + var(--ac-space-5)); + display: flex; + flex-direction: column; + justify-content: center; + gap: var(--ac-space-1); +} +.own-aa-family__info::before { + border-radius: 0 var(--ac-radius-card) var(--ac-radius-card) 0; + background: var(--ac-color-bg-app); + clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%, var(--chevron) 50%); +} +.own-aa-family__name { + margin: 0; + font: var(--ac-font-heading); + color: var(--ac-color-text); +} +.own-aa-family__meta { + font: var(--ac-font-small); + color: var(--ac-color-text-muted); +} +.own-aa-family__uses { + font: var(--ac-font-caption); + color: var(--ac-color-text-2); +} +/* The family's cards, 1st on the left, fanned out as in Re:Builder's brush; a token's or a hero's single card large. + Each card opens large on a click. */ +.own-aa-fan { + --card: 100px; + --step: 64px; + position: relative; + height: calc(var(--card) * 7 / 5 + var(--ac-space-5)); + margin-left: var(--ac-space-2); +} +.own-aa-fan--single { + --card: 164px; +} +.own-aa-fan__card { + position: absolute; + top: var(--ac-space-2); + left: calc(var(--i) * var(--step)); + width: var(--card); + padding: 0; + border: 0; + border-radius: var(--ac-radius-sm); + background: none; + cursor: zoom-in; + transform: rotate(calc((var(--i) - (var(--n) - 1) / 2) * 6deg)); + transform-origin: 50% 120%; + transition: transform var(--ac-duration-fast); +} +.own-aa-fan__card img { + display: block; + width: 100%; + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: inherit; + box-shadow: var(--ac-shadow-card); +} +.own-aa-fan__card:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: 2px; + z-index: 1; +} +@media (hover: hover) { + .own-aa-fan__card:hover { + z-index: 1; + transform: rotate(calc((var(--i) - (var(--n) - 1) / 2) * 6deg)) translateY(calc(-1 * var(--ac-space-1))); } } +.own-aa-family > .own-aa-row { + flex: 999 1 420px; + min-width: 0; +} @media (max-width: 767.98px) { - .own-aa-strip { - grid-template-columns: repeat(2, 1fr); - gap: var(--ac-space-1-5); + .own-aa-family { + padding: var(--ac-space-4); + } + .own-aa-family { + --chevron: 28px; + } + .own-aa-family > .own-aa-row { + flex-basis: 100%; + padding: var(--ac-space-4) var(--ac-space-4) calc(var(--chevron) + var(--ac-space-4)); + } + .own-aa-family > .own-aa-row::before { + border-radius: var(--ac-radius-card) var(--ac-radius-card) 0 0; + clip-path: polygon(0 0, 100% 0, 100% calc(100% - var(--chevron)), 50% 100%, 0 calc(100% - var(--chevron))); + } + .own-aa-family__info { + flex-basis: 100%; + margin: calc(var(--ac-space-3) - var(--chevron) - var(--ac-space-4)) 0 0; + padding: calc(var(--chevron) + var(--ac-space-4)) var(--ac-space-5) var(--ac-space-5); + align-items: center; + text-align: center; + } + /* Centred under the chevron: as wide as its cards. */ + .own-aa-fan { + width: calc(var(--card) + (var(--n, 1) - 1) * var(--step)); + margin-left: 0; + } + .own-aa-family__info::before { + border-radius: 0 0 var(--ac-radius-card) var(--ac-radius-card); + clip-path: polygon(0 0, 50% var(--chevron), 100% 0, 100% 100%, 0 100%); } } -.own-aa-tile { + +/* One family's illustrations, each under its keys, on one line that scrolls sideways (js/alt-art-widget.js; also in + the card modals). An arrow on each side while there is more that way. */ +.own-aa-scroller { position: relative; - min-width: 0; - cursor: pointer; } -/* Direct child only — .own-aa-tile also nests the marker s one level deeper - inside .own-aa-markers, and a descendant selector here would out-specify - .own-aa-marker's own width and blow the markers up to the tile's full size. */ -.own-aa-tile > img { +.own-aa-strip { + display: flex; + gap: var(--ac-space-5); + overflow-x: auto; + overscroll-behavior-x: contain; + scroll-snap-type: x mandatory; + scroll-padding-inline: var(--ac-space-1); + padding: var(--ac-space-1); + margin: calc(-1 * var(--ac-space-1)); + scrollbar-width: thin; +} +.own-aa-strip > .own-aa-print { + flex: none; + scroll-snap-align: start; +} +/* The illustration cut at an edge fades out while there is more that way (any opaque colour: the mask uses alpha). */ +.own-aa-scroller { + --fade-start: 0px; + --fade-end: 0px; +} +.own-aa-scroller--more-start { + --fade-start: min(96px, 10%); +} +.own-aa-scroller--more-end { + --fade-end: min(96px, 10%); +} +.own-aa-scroller--more-start .own-aa-strip, +.own-aa-scroller--more-end .own-aa-strip { + -webkit-mask-image: linear-gradient(to right, transparent, var(--ac-color-text) var(--fade-start), var(--ac-color-text) calc(100% - var(--fade-end)), transparent); + mask-image: linear-gradient(to right, transparent, var(--ac-color-text) var(--fade-start), var(--ac-color-text) calc(100% - var(--fade-end)), transparent); +} +.own-aa-scroller__arrow { + position: absolute; + top: 50%; + z-index: 2; + transform: translateY(-50%); + border-radius: var(--ac-radius-pill); + box-shadow: var(--ac-shadow-e2); +} +.own-aa-scroller__arrow[hidden] { + display: none; +} +.own-aa-scroller__arrow--prev { + left: calc(-1 * var(--ac-space-3)); +} +.own-aa-scroller__arrow--next { + right: calc(-1 * var(--ac-space-3)); +} +.own-aa-print { + width: 200px; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ac-space-0-5); + text-align: center; +} +.own-aa-keys { + align-self: stretch; + margin-bottom: var(--ac-space-1-5); +} +.own-aa-print__art { display: block; width: 100%; - border-radius: var(--ac-radius-md); - aspect-ratio: 63.5/88; + margin-bottom: var(--ac-space-1-5); + aspect-ratio: 63.5 / 88; object-fit: cover; - transition: transform var(--ac-duration-fast); + border-radius: var(--ac-radius-md); background: var(--ac-color-track); + transition: transform var(--ac-duration-fast); } -.own-aa-tile:hover > img { - transform: scale(1.03); +.own-aa-print__zoom { + display: block; + width: 100%; + padding: 0; + border: 0; + border-radius: var(--ac-radius-md); + background: none; + cursor: zoom-in; } -.own-aa-tile--unowned { - cursor: not-allowed; +.own-aa-print__zoom:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: 2px; } -.own-aa-tile--unowned > img { +@media (hover: hover) { + .own-aa-print__zoom:hover .own-aa-print__art { + transform: translateY(calc(-1 * var(--ac-space-1))); + } +} +.own-aa-print--unowned .own-aa-print__art { opacity: .35; filter: grayscale(1); } -.own-aa-tile--unowned:hover > img { - transform: none; -} -.own-aa-markers { - position: absolute; - top: var(--ac-space-1); - right: var(--ac-space-1); - z-index: 2; -} -/* Positioned by --slot (the owned copy's slotIndex, set in JS) rather than stacked in flex/DOM - order — that height stays the same copy's height on every tile, so a lone marker on a tile - shows at a glance which copy (1st/2nd/3rd, i.e. which deck slot — see distributeAcrossSlots - in deckbuilder.php) it is, instead of always looking like "the first one" at the top. */ -.own-aa-marker { - position: absolute; - top: calc(var(--slot, 0) * 22px); - right: 0; - width: 20px; - height: auto; - cursor: pointer; - filter: drop-shadow(0 1px 2px var(--ac-color-overlay-label)); +.own-aa-print__label { + font: var(--ac-font-body-strong); + color: var(--ac-color-text); } -.own-aa-marker--active { - outline: 2px solid var(--ac-color-primary); - border-radius: var(--ac-radius-xs); +.own-aa-print__note { + font: var(--ac-font-caption); + color: var(--ac-color-text-muted); } .own-aa-row-error { - margin-top: var(--ac-space-1); + margin-top: var(--ac-space-2); + font: var(--ac-font-small); + color: var(--ac-color-danger); } -.own-aa-sep { - border: none; - border-top: 1px solid var(--ac-color-divider); - margin: var(--ac-space-3) 0; +/* Two illustrations in view on a phone (and a bit of the next one). */ +@media (max-width: 767.98px) { + .own-aa-strip { + gap: var(--ac-space-4); + } + .own-aa-strip > .own-aa-print { + width: calc((100% - var(--ac-space-4)) / 2.2); + } } -/* Per-deck vs. global alt-art mode toggle + its explanation panel (pages/alt-arts.php) */ +/* Window over the page (the legacy deckbuilder's token illustrations, assets/deckbuilder/token-arts.js) */ .own-aa-modal-overlay { position: fixed; inset: 0; @@ -442,9 +635,6 @@ overflow-y: auto; box-shadow: var(--ac-shadow-dialog); } -.own-aa-modal .ac-card__meta { - margin-bottom: var(--ac-space-3); -} /* Mode switch bar and search field (pages/alt-arts.php) */ .own-aa-modebar { diff --git a/plugins/ownership/e2e/alt-arts.spec.ts b/plugins/ownership/e2e/alt-arts.spec.ts new file mode 100644 index 00000000..fbce73c1 --- /dev/null +++ b/plugins/ownership/e2e/alt-arts.spec.ts @@ -0,0 +1,103 @@ +import { evidence, expect, login, test, type Page } from '../../../tests/e2e/fixtures'; + +const FEE = { base: 'ALT_CORE_B_AX_09_C', alt: 'ALT_MUSUBI_B_AX_09_C' }; +const SCARABOT = { base: 'ALT_CORE_B_AX_31_C', alt: 'ALT_CORE_A_AX_31_C' }; + +/** + * The page's search (`/papi/ownership/alt-art-search`) answers two families: Fée Clochette (the plain print and + * an alt art owned once, the three cards on the plain print) and the Scarabot token (one card). The choices saved + * (`/papi/ownership/alt-art-set-preference`) are kept here and answered without the service (204, or `answer`). + */ +async function routeAltArts(page: Page, answer: { status: number; json?: unknown } = { status: 204 }): Promise { + const family = (familyId: number, cardType: string, name: string, refs: { base: string; alt: string }, cards: number) => ({ + family: { familyId, faction: 'AX', rarity: 'C', cardType, name, reference: refs.base }, + options: { + familyId, + faction: 'AX', + rarity: 'C', + options: [ + { reference: refs.base, ownedQuantity: null }, + { reference: refs.alt, ownedQuantity: 1 }, + ], + slots: Array.from({ length: cards }, (_, i) => ({ slotIndex: i + 1, reference: refs.base, isExplicitChoice: false })), + }, + }); + const fee = family(1, 'CHARACTER', 'Fée Clochette', FEE, 3); + const scarabot = family(2, 'TOKEN', 'Scarabot', SCARABOT, 1); + await page.route('**/papi/ownership/alt-art-search**', (route) => + route.fulfill({ + json: { + families: [fee.family, scarabot.family], + options: { '1:AX:C': fee.options, '2:AX:C': scarabot.options }, + hasMore: false, + }, + }), + ); + const saved: string[][] = []; + await page.route('**/papi/ownership/alt-art-set-preference', async (route) => { + saved.push((route.request().postDataJSON() as { slotReferences: string[] }).slotReferences); + await route.fulfill(answer); + }); + return saved; +} + +test('chooses the default alt arts with the keys above each illustration', { tag: '@mobile' }, async ({ page }, testInfo) => { + const saved = await routeAltArts(page); + await login(page, 'alice', '/pages/ownership-alt-arts?lang=fr'); + const fee = page.getByRole('article').filter({ has: page.getByRole('heading', { name: 'Fée Clochette' }) }); + const key = (n: number, card: string) => fee.getByRole('group', { name: `Cartes de Illustration ${n}` }).getByRole('button', { name: card }); + // The three cards take the plain print. + for (const card of ['1ère carte', '2ème carte', '3ème carte']) { + await expect(key(1, card)).toHaveAttribute('aria-pressed', 'true'); + await expect(key(2, card)).toHaveAttribute('aria-pressed', 'false'); + } + // Per deck, the defaults go to cards added to a deck; in « Global » (the player's mode, kept by the service), to every deck. + const global = await page.evaluate(() => (window as unknown as { OWN_AA_CONFIG: { isGlobalMode: boolean } }).OWN_AA_CONFIG.isGlobalMode); + await expect(fee.getByText(global ? 'Tous vos decks utilisent' : 'Une carte ajoutée à un deck prend')).toBeVisible(); + // The 1st card takes the alt art: saved, and the fan shows it first. + await key(2, '1ère carte').click(); + await expect.poll(() => saved.at(-1)).toEqual([FEE.alt, FEE.base, FEE.base]); + await expect(key(2, '1ère carte')).toHaveAttribute('aria-pressed', 'true'); + await expect(key(1, '1ère carte')).toHaveAttribute('aria-pressed', 'false'); + await expect(fee.locator('.own-aa-fan img').first()).toHaveAttribute('src', new RegExp(FEE.alt)); + await expect(fee.getByRole('button', { name: 'Agrandir 1ère carte : Illustration 2' })).toBeVisible(); + // A card of the fan, or an illustration, opens large as on the cards pages. + const zoom = page.locator('#card-modal'); + await fee.getByRole('button', { name: 'Agrandir 1ère carte : Illustration 2' }).click(); + await expect(zoom).toBeVisible(); + await expect(zoom.locator('img').first()).toHaveAttribute('src', new RegExp(FEE.alt)); + await expect(zoom.getByRole('link', { name: 'Accéder au détail' })).toBeVisible(); + await evidence(page, testInfo, '41-default-alt-arts-zoom'); + await page.keyboard.press('Escape'); + await expect(zoom).toBeHidden(); + await fee.getByRole('button', { name: 'Agrandir Illustration 1' }).click(); + await expect(zoom.locator('img').first()).toHaveAttribute('src', new RegExp(FEE.base)); + await page.keyboard.press('Escape'); + // Its only copy owned is on the 1st card: the other cards cannot take it. + await expect(key(2, '2ème carte')).toBeDisabled(); + await expect(key(2, '3ème carte')).toBeDisabled(); + // A token has one key an illustration. + const scarabot = page.getByRole('article').filter({ has: page.getByRole('heading', { name: 'Scarabot' }) }); + await expect(scarabot.getByText('Tous vos decks utilisent')).toBeVisible(); + const token = scarabot.getByRole('group', { name: 'Cartes de Illustration 2' }).getByRole('button'); + await token.click(); + await expect.poll(() => saved.at(-1)).toEqual([SCARABOT.alt]); + await expect(token).toHaveAccessibleName('Choisie'); + await expect(token).toHaveAttribute('aria-pressed', 'true'); + await fee.scrollIntoViewIfNeeded(); + await evidence(page, testInfo, '40-default-alt-arts-keys'); +}); + +test('puts the keys back when the service refuses a choice', async ({ page }) => { + const saved = await routeAltArts(page, { status: 409, json: [{ reference: FEE.alt, requested: 2, owned: 1 }] }); + await login(page, 'alice', '/pages/ownership-alt-arts?lang=fr'); + const fee = page.getByRole('article').filter({ has: page.getByRole('heading', { name: 'Fée Clochette' }) }); + const key = (n: number, card: string) => fee.getByRole('group', { name: `Cartes de Illustration ${n}` }).getByRole('button', { name: card }); + await key(2, '1ère carte').click(); + await expect.poll(() => saved.at(-1)).toEqual([FEE.alt, FEE.base, FEE.base]); + // Refused: the 1st card takes the plain print again, the fan with it, and the reason is shown. + await expect(fee.getByRole('alert')).toContainText(`${FEE.alt} (2/1)`); + await expect(key(1, '1ère carte')).toHaveAttribute('aria-pressed', 'true'); + await expect(key(2, '1ère carte')).toHaveAttribute('aria-pressed', 'false'); + await expect(fee.locator('.own-aa-fan img').first()).toHaveAttribute('src', new RegExp(FEE.base)); +}); diff --git a/plugins/ownership/includes/subnav.php b/plugins/ownership/includes/subnav.php index 0990b973..6fb86912 100644 --- a/plugins/ownership/includes/subnav.php +++ b/plugins/ownership/includes/subnav.php @@ -14,8 +14,8 @@ // physical-collection tab elsewhere on the site (core-altered-cards/pages/collection.php); // keeping "Digital Ownership"/"Propriété numérique" here avoids the two being confused. $ownSubnavTxt = [ - 'en' => ['collection' => 'Digital Ownership', 'boosters' => 'Boosters', 'history' => 'History', 'altArts' => 'Alt Arts BGA', 'import' => 'Equinox Import'], - 'fr' => ['collection' => 'Propriété numérique', 'boosters' => 'Boosters', 'history' => 'Historique', 'altArts' => 'Alt Arts BGA', 'import' => 'Import Equinox'], + 'en' => ['collection' => 'Digital Ownership', 'boosters' => 'Boosters', 'history' => 'History', 'altArts' => 'Default alt arts', 'import' => 'Equinox Import'], + 'fr' => ['collection' => 'Propriété numérique', 'boosters' => 'Boosters', 'history' => 'Historique', 'altArts' => 'Arts alternatifs par défaut', 'import' => 'Import Equinox'], ][getUiLang()]; $ownBoosterCount = null; diff --git a/plugins/ownership/js/alt-art-widget.js b/plugins/ownership/js/alt-art-widget.js index b9d5477f..eeaa72d0 100644 --- a/plugins/ownership/js/alt-art-widget.js +++ b/plugins/ownership/js/alt-art-widget.js @@ -1,6 +1,6 @@ -// Reusable single-family alt-art marker widget: renders one interactive tile strip -// (one tile per illustration of a card family) with the player's copy markers, -// click-to-move, optimistic UI with server-authoritative rollback on a 409 shortfall. +// Reusable single-family alt-art widget: one illustration per print of a card family, each under its +// keys (the 1st, 2nd and 3rd card, design system `ac-keys`), optimistic UI with server-authoritative +// rollback on a 409 shortfall. // Extracted from pages/alt-arts.php's per-family rendering so the exact same logic can // be embedded in card-detail modals (see card-modal-enhance.js), scoped to one family // instead of a paginated multi-family search. @@ -41,76 +41,174 @@ return cfg.cdnUrl + '/cards/' + cfg.lang + '/' + (p[1] || '') + '/' + ref + '.webp'; }; - // Builds one `.own-aa-row` for a single family. cfg = {cdnUrl, lang, markerImg, - // setPreferenceUrl, csrfToken, txt:{saveError}}. Returns {el, destroy()}. + // Texts of the widget, in the page's language (the card modals that embed it pass only a save error). + const TXT = { + fr: { + cards: ['1ère carte', '2ème carte', '3ème carte'], suffixes: ['ère', 'ème', 'ème'], card: 'carte', + choose: 'Choisir', chosen: 'Choisie', illustration: 'Illustration %s', cardsOf: 'Cartes de %s', + unlimited: 'Illimitée', notOwned: 'Non possédée', ownedOne: '1 possédée', ownedMany: '%s possédées', colon: ' : ', zoom: 'Agrandir %s', previous: 'Illustrations précédentes', next: 'Illustrations suivantes', + }, + en: { + cards: ['1st card', '2nd card', '3rd card'], suffixes: ['st', 'nd', 'rd'], card: 'card', + choose: 'Choose', chosen: 'Chosen', illustration: 'Illustration %s', cardsOf: 'Cards of %s', + unlimited: 'Unlimited', notOwned: 'Not owned', ownedOne: '1 owned', ownedMany: '%s owned', colon: ': ', zoom: 'Enlarge %s', previous: 'Previous illustrations', next: 'Next illustrations', + }, + }; + const texts = () => TXT[(document.documentElement.lang || 'en').slice(0, 2)] || TXT.en; + + // Builds one `.own-aa-row` for a single family: each illustration under its keys, the 1st, 2nd and 3rd card + // (one key for a token). A key pressed is a card that takes the illustration; pressing another gives it that + // card. cfg = {cdnUrl, lang, setPreferenceUrl, csrfToken, txt:{saveError}, onChange(references, labels)?, onZoom(reference)?}. + // Returns {el, destroy()}. function createFamilyRow(family, optData, cfg) { const groupKey = family.familyId + ':' + family.faction + ':' + family.rarity; + const txt = texts(); - const state = { - slots: optData.slots.slice().sort((a, b) => a.slotIndex - b.slotIndex) - .map((s) => ({ slotIndex: s.slotIndex, reference: s.reference })), - // Rotation starts at slot 1 so successive clicks fill slots 1, 2, 3 in order. - activeSlot: optData.slots.slice().sort((a, b) => a.slotIndex - b.slotIndex)[0].slotIndex, - }; + // One slot a card (1 for a token), 1st first. + const slots = optData.slots.slice().sort((a, b) => a.slotIndex - b.slotIndex) + .map((s) => ({ slotIndex: s.slotIndex, reference: s.reference })); + const single = slots.length === 1; // Last slot references the server confirmed -- what a failed save rolls back to. - let savedRefs = state.slots.map((s) => s.reference); - - const renderTile = (opt) => { - const tile = document.createElement('div'); - tile.className = 'own-aa-tile'; - tile.dataset.ref = opt.reference; - tile.dataset.group = groupKey; - const unowned = opt.ownedQuantity !== null && opt.ownedQuantity === 0; - if (unowned) tile.classList.add('own-aa-tile--unowned'); - - const img = document.createElement('img'); - img.src = cdnUrl(cfg, opt.reference); - img.loading = 'lazy'; - img.alt = ''; - tile.appendChild(img); - - const markers = document.createElement('div'); - markers.className = 'own-aa-markers'; - tile.appendChild(markers); - return tile; - }; + let savedRefs = slots.map((s) => s.reference); const row = document.createElement('div'); row.className = 'own-aa-row'; row.dataset.group = groupKey; + // The illustrations on one line that scrolls sideways, an arrow on each side while there is more that way. + const scroller = document.createElement('div'); + scroller.className = 'own-aa-scroller'; const strip = document.createElement('div'); strip.className = 'own-aa-strip'; - strip.dataset.group = groupKey; - optData.options.forEach((opt) => strip.appendChild(renderTile(opt))); - row.appendChild(strip); + const arrow = (dir, icon, label) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'ac-icon-button own-aa-scroller__arrow own-aa-scroller__arrow--' + dir; + btn.setAttribute('aria-label', label); + btn.innerHTML = window.acIcon ? window.acIcon(icon) : (dir === 'prev' ? '‹' : '›'); + btn.hidden = true; + btn.addEventListener('click', () => { + strip.scrollBy({ left: (dir === 'prev' ? -1 : 1) * strip.clientWidth * 0.8, behavior: 'smooth' }); + }); + return btn; + }; + const prev = arrow('prev', 'chevron-left', txt.previous); + const next = arrow('next', 'chevron-right', txt.next); + scroller.append(prev, strip, next); + row.appendChild(scroller); + const updateArrows = () => { + const max = strip.scrollWidth - strip.clientWidth; + prev.hidden = strip.scrollLeft <= 1; + next.hidden = strip.scrollLeft >= max - 1; + // The cut illustration fades out on the side where there is more. + scroller.classList.toggle('own-aa-scroller--more-start', !prev.hidden); + scroller.classList.toggle('own-aa-scroller--more-end', !next.hidden); + }; + strip.addEventListener('scroll', updateArrows, { passive: true }); + const resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(updateArrows) : null; + if (resizeObserver) resizeObserver.observe(strip); const err = document.createElement('div'); - err.className = 'own-aa-row-error text-danger small'; + err.className = 'own-aa-row-error'; + err.setAttribute('role', 'alert'); err.hidden = true; row.appendChild(err); const showError = (message) => { err.textContent = message; err.hidden = false; }; const clearError = () => { err.hidden = true; }; - // Moves each slot's marker into whichever tile currently holds its reference. - const applyMarkers = () => { - strip.querySelectorAll('.own-aa-markers').forEach((el) => { el.innerHTML = ''; }); - state.slots.forEach((slot) => { - const tile = strip.querySelector('.own-aa-tile[data-ref="' + CSS.escape(slot.reference) + '"]'); - if (!tile) return; - const marker = document.createElement('img'); - marker.className = 'own-aa-marker' + (slot.slotIndex === state.activeSlot ? ' own-aa-marker--active' : ''); - marker.src = cfg.markerImg; - marker.dataset.slot = String(slot.slotIndex); - marker.dataset.group = groupKey; - marker.style.setProperty('--slot', slot.slotIndex); - marker.alt = ''; - tile.querySelector('.own-aa-markers').appendChild(marker); + // A card can take this illustration unless the copies owned of it are all on other cards. + const canTake = (opt, index) => opt.ownedQuantity === null + || slots.filter((s, i) => i !== index && s.reference === opt.reference).length < opt.ownedQuantity; + + const noteOf = (opt) => opt.ownedQuantity === null ? txt.unlimited + : opt.ownedQuantity === 0 ? txt.notOwned + : opt.ownedQuantity === 1 ? txt.ownedOne : txt.ownedMany.replace('%s', opt.ownedQuantity); + + const keyButton = (opt, index) => { + const key = document.createElement('button'); + key.type = 'button'; + key.className = 'ac-key'; + key.dataset.ref = opt.reference; + key.dataset.slot = String(index); + if (single) { + // Named by its text (« Choisir » / « Choisie ») within the group « Cartes de Illustration n ». + key.innerHTML = ''; + } else { + key.setAttribute('aria-label', txt.cards[index] || (index + 1) + txt.suffixes[2] + ' ' + txt.card); + key.innerHTML = '' + (index + 1) + '' + (txt.suffixes[index] || txt.suffixes[2]) + + '' + txt.card + ''; + } + return key; + }; + + optData.options.forEach((opt, i) => { + const label = txt.illustration.replace('%s', i + 1); + const print = document.createElement('div'); + print.className = 'own-aa-print'; + if (opt.ownedQuantity === 0) print.classList.add('own-aa-print--unowned'); + + const keys = document.createElement('div'); + keys.className = 'ac-keys own-aa-keys'; + keys.style.setProperty('--ac-keys-count', String(slots.length)); + keys.setAttribute('role', 'group'); + keys.setAttribute('aria-label', txt.cardsOf.replace('%s', label)); + slots.forEach((s, index) => keys.appendChild(keyButton(opt, index))); + print.appendChild(keys); + + const img = document.createElement('img'); + img.className = 'own-aa-print__art'; + img.src = cdnUrl(cfg, opt.reference); + img.loading = 'lazy'; + img.alt = ''; + // With a zoom from the host page, the illustration opens large. + if (cfg.onZoom) { + const zoom = document.createElement('button'); + zoom.type = 'button'; + zoom.className = 'own-aa-print__zoom'; + zoom.setAttribute('aria-label', txt.zoom.replace('%s', label)); + zoom.addEventListener('click', () => cfg.onZoom(opt.reference)); + zoom.appendChild(img); + print.appendChild(zoom); + } else { + print.appendChild(img); + } + + const name = document.createElement('span'); + name.className = 'own-aa-print__label'; + name.textContent = label; + print.appendChild(name); + const note = document.createElement('span'); + note.className = 'own-aa-print__note'; + note.textContent = noteOf(opt); + print.appendChild(note); + + strip.appendChild(print); + }); + + const options = new Map(optData.options.map((o) => [o.reference, o])); + const illustrationOf = (ref) => txt.illustration.replace('%s', optData.options.findIndex((o) => o.reference === ref) + 1); + + // What each card takes, in words (« 1ère carte : Illustration 2 »), and how to name its zoom. + const describe = () => slots.map((s, i) => { + const text = single ? illustrationOf(s.reference) : (txt.cards[i] || '') + txt.colon + illustrationOf(s.reference); + return { text, zoom: txt.zoom.replace('%s', text) }; + }); + + // Each key pressed when its card takes the key's illustration, disabled when it cannot take it. + const render = () => { + strip.querySelectorAll('.ac-key').forEach((key) => { + const index = parseInt(key.dataset.slot, 10); + const opt = options.get(key.dataset.ref); + const on = slots[index].reference === opt.reference; + key.setAttribute('aria-pressed', on ? 'true' : 'false'); + key.disabled = !on && !canTake(opt, index); + if (single) key.querySelector('.ac-key__label').textContent = on ? txt.chosen : txt.choose; }); + if (cfg.onChange) cfg.onChange(slots.map((s) => s.reference), describe()); }; - applyMarkers(); + render(); const saveGroup = async (slotReferences) => { const res = await fetch(cfg.setPreferenceUrl, { @@ -138,11 +236,9 @@ throw new Error(saveErrorMsg); }; - // One save in flight at a time, always sending the latest marker state: firing a - // request per click let two quick clicks race server-side, so the stored slots - // could end up on the older click while the markers showed the newer one (e.g. - // both top markers on an alt art, yet a 2-copy deck got one alt + one base). - // Clicks made while a save is in flight are coalesced into one follow-up save. + // One save in flight at a time, always sending the latest key state: firing a request per click let two + // quick clicks race server-side, so the stored slots could end up on the older click while the keys showed + // the newer one. Clicks made while a save is in flight are coalesced into one follow-up save. let saving = false; let savePending = false; const requestSave = async () => { @@ -151,7 +247,7 @@ let lastError = null; do { savePending = false; - const refs = state.slots.map((s) => s.reference); + const refs = slots.map((s) => s.reference); try { await saveGroup(refs); savedRefs = refs; @@ -163,8 +259,8 @@ saving = false; if (lastError) { - state.slots.forEach((s, i) => { s.reference = savedRefs[i]; }); - applyMarkers(); + slots.forEach((s, i) => { s.reference = savedRefs[i]; }); + render(); showError(lastError.message); return; } @@ -176,55 +272,26 @@ })); }; - // After a successful move, the active slot advances to the next one in sequence - // (wrapping around) so consecutive clicks on different cards spread across all the - // player's markers instead of always redirecting the same one. Explicitly clicking - // a marker still overrides this and pins that slot as the next to move. - const advanceActiveSlot = () => { - const idx = state.slots.findIndex((s) => s.slotIndex === state.activeSlot); - const next = state.slots[(idx + 1) % state.slots.length]; - state.activeSlot = next.slotIndex; - }; - - const moveActiveMarker = (newRef) => { - // The active slot is only a preferred starting point, not a hard requirement: if - // it's already on the clicked tile (e.g. a 2/1 split where the active marker sits - // on the minority tile the player just clicked), moving it would be a no-op even - // though another marker elsewhere clearly isn't on this tile yet. So walk the - // rotation from the active slot and grab the first marker that isn't already here. - const startIdx = state.slots.findIndex((s) => s.slotIndex === state.activeSlot); - let slot = null; - for (let i = 0; i < state.slots.length; i++) { - const candidate = state.slots[(startIdx + i) % state.slots.length]; - if (candidate.reference !== newRef) { slot = candidate; break; } - } - if (!slot) return; // every marker is already on this tile — nothing to move - - slot.reference = newRef; - state.activeSlot = slot.slotIndex; - advanceActiveSlot(); - applyMarkers(); + // A key: its card takes its illustration. + const onClick = (e) => { + const key = e.target.closest('.ac-key'); + if (!key) return; + const slot = slots[parseInt(key.dataset.slot, 10)]; + if (!slot || slot.reference === key.dataset.ref) return; + slot.reference = key.dataset.ref; + render(); clearError(); requestSave(); }; - - const onClick = (e) => { - const marker = e.target.closest('.own-aa-marker'); - if (marker) { - state.activeSlot = parseInt(marker.dataset.slot, 10); - applyMarkers(); - return; - } - const tile = e.target.closest('.own-aa-tile'); - if (!tile || tile.classList.contains('own-aa-tile--unowned')) return; - moveActiveMarker(tile.dataset.ref); - }; row.addEventListener('click', onClick); - const destroy = () => { row.removeEventListener('click', onClick); }; + const destroy = () => { + row.removeEventListener('click', onClick); + if (resizeObserver) resizeObserver.disconnect(); + }; return { el: row, destroy }; } - window.OWN_ALT_ART_WIDGET = { hasRealChoice, fetchSingleFamily, createFamilyRow }; + window.OWN_ALT_ART_WIDGET = { hasRealChoice, fetchSingleFamily, createFamilyRow, cdnUrl }; })(); diff --git a/plugins/ownership/js/alt-arts.js b/plugins/ownership/js/alt-arts.js index f9cefb4e..7e30a0a0 100644 --- a/plugins/ownership/js/alt-arts.js +++ b/plugins/ownership/js/alt-arts.js @@ -14,7 +14,6 @@ const widgetCfg = { cdnUrl: cfg.cdnUrl, lang: cfg.lang, - markerImg: cfg.markerImg, setPreferenceUrl: cfg.setPreferenceUrl, csrfToken: cfg.csrfToken, txt: { saveError: t('saveError', 'Could not save your choice.') }, @@ -165,20 +164,76 @@ loadMoreBtn?.addEventListener('click', renderMore); // ---- Rendering ---- + const labelOf = (map, code) => (cfg.labels && cfg.labels[map] && cfg.labels[map][code]) || code; + + // One family: its name, its illustrations under their keys, then what decks take (its cards fanned out, the 1st on + // the left). const renderFamilyRow = (family) => { const key = groupKeyOf(family.familyId, family.faction, family.rarity); const optData = optionsByKey[key]; - const wrap = document.createElement('div'); - if (!optData || !optData.options || !optData.options.length) return wrap; // shouldn't happen - - const widgetRow = window.OWN_ALT_ART_WIDGET.createFamilyRow(family, optData, widgetCfg); - wrap.appendChild(widgetRow.el); - - const sep = document.createElement('hr'); - sep.className = 'own-aa-sep'; - wrap.appendChild(sep); - return wrap; + const row = document.createElement('article'); + row.className = 'own-aa-family'; + if (!optData || !optData.options || !optData.options.length) return row; // shouldn't happen + + const info = document.createElement('div'); + info.className = 'own-aa-family__info'; + const name = document.createElement('h2'); + name.className = 'own-aa-family__name'; + name.textContent = family.name || family.reference || ''; + const meta = document.createElement('span'); + meta.className = 'own-aa-family__meta'; + meta.textContent = [labelOf('type', family.cardType), labelOf('faction', family.faction), labelOf('rarity', family.rarity)] + .filter(Boolean).join(' · '); + const uses = document.createElement('span'); + uses.className = 'own-aa-family__uses'; + uses.textContent = optData.slots.length === 1 || cfg.isGlobalMode + ? t('usesAll', 'All your decks use') + : t('usesNew', 'A card added to a deck takes'); + const fan = document.createElement('div'); + fan.className = 'own-aa-fan'; + if (optData.slots.length === 1) fan.classList.add('own-aa-fan--single'); + // The card's name on top; its illustrations and their keys on the left, what decks take on the right (as in + // Re:Builder's brush: the choices, then the result). + const head = document.createElement('div'); + head.className = 'own-aa-family__head'; + head.append(name, meta); + info.append(uses, fan); + row.appendChild(head); + + // A card large, as on the cards pages (pages/_card-zoom-modal.php of core-altered-cards). + const zoom = (ref) => { + if (window.acOpenCardZoom) window.acOpenCardZoom(ref, false, cfg.lang, window.OWN_ALT_ART_WIDGET.cdnUrl(cfg, ref)); + }; + + // One button a card, made once; a change only swaps their images and names. + const drawFan = (references, labels) => { + fan.style.setProperty('--n', references.length); + references.forEach((ref, i) => { + let card = fan.children[i]; + if (!card) { + card = document.createElement('button'); + card.type = 'button'; + card.className = 'own-aa-fan__card'; + card.style.setProperty('--i', i); + const img = document.createElement('img'); + img.alt = ''; + img.loading = 'lazy'; + card.appendChild(img); + card.addEventListener('click', () => zoom(card.dataset.ref)); + fan.appendChild(card); + } + card.dataset.ref = ref; + card.setAttribute('aria-label', labels[i].zoom); + const img = card.firstChild; + const src = window.OWN_ALT_ART_WIDGET.cdnUrl(cfg, ref); + if (img.getAttribute('src') !== src) img.src = src; + }); + }; + + const widgetRow = window.OWN_ALT_ART_WIDGET.createFamilyRow(family, optData, { ...widgetCfg, onChange: drawFan, onZoom: zoom }); + row.append(widgetRow.el, info); + return row; }; runSearch(); diff --git a/plugins/ownership/pages/alt-arts.php b/plugins/ownership/pages/alt-arts.php index ea4032f5..43596f62 100644 --- a/plugins/ownership/pages/alt-arts.php +++ b/plugins/ownership/pages/alt-arts.php @@ -1,13 +1,15 @@ 'Network error.', 'saveError' => 'Could not save your choice.', 'loadMore' => 'Load more', - 'markerHint' => 'Click a marker, then click a card to move it there.', - 'modePerDeck' => 'Set per deck', - 'modeGlobal' => 'Set globally', - 'modeInfoBtn' => 'What\'s the difference?', - 'modeInfoTitle' => 'Per deck vs. global illustrations', - 'modePerDeckTitle' => 'Per deck (default)', - 'modePerDeckDesc' => "Clicking a card no longer shows the illustration preference. Only tokens can be changed on this page. In the deckbuilder, you choose each card's and the hero's illustration individually while building the deck.", - 'modeGlobalTitle' => 'Global', - 'modeGlobalDesc' => 'Your illustration preferences are set here or by clicking a card, and apply automatically everywhere: opening a deck to edit it, duplicating it, importing it, retrieving it from BGA, and whenever you change a card\'s quantity. The deckbuilder no longer lets you change illustrations card by card, and there is no "apply preferences" button — it always applies.', - 'modeClose' => 'Close', - 'modeSaving' => 'Saving…', + 'keysHint' => 'Click a box above an illustration to give it that card.', + 'intro' => 'Choose the illustration of the 1st, 2nd and 3rd card of each card: your default alt arts. A token has a single illustration, the same in all your decks.', + 'usesNew' => 'A card added to a deck takes', + 'usesAll' => 'All your decks use', + 'results' => 'Cards', + 'detail_label' => 'View detail', + 'modeLabel' => 'How your decks use them', + 'modePerDeck' => 'Per deck', + 'modeGlobal' => 'Global', + 'modePerDeckDesc' => 'A card added to a deck in the deck builder takes your default alt arts. Each deck then keeps its own illustrations, which you can change card by card for that deck only. Board Game Arena shows each deck\'s illustrations.', + 'modeGlobalDesc' => 'Your default alt arts apply to all your decks, on this site and on Board Game Arena, whenever a deck is opened or changed. Illustrations cannot be changed deck by deck.', + 'toGlobalTitle' => 'Switch to global mode?', + 'toGlobalText' => 'All your decks will follow your default alt arts. The illustrations chosen deck by deck will be replaced.', + 'toGlobalConfirm' => 'Switch to global', + 'toPerDeckTitle' => 'Switch to per-deck mode?', + 'toPerDeckText' => 'Your default alt arts will be written in all your decks. Each deck then keeps its illustrations, which you can change in the deck builder.', + 'toPerDeckConfirm' => 'Switch to per deck', + 'cancel' => 'Cancel', + 'progress' => 'Decks updated: %1$s / %2$s', 'modeSaveError' => 'Could not save your setting.', - 'perDeckNotice' => 'Per-deck mode is on: only token illustrations are managed here. Regular cards\' illustrations are chosen individually in each deck, in the deckbuilder.', ], 'fr' => [ 'unavailable' => 'Le service de propriété numérique n\'est pas configuré sur ce site.', @@ -57,19 +66,26 @@ 'networkError' => 'Erreur réseau.', 'saveError' => 'Impossible d\'enregistrer votre choix.', 'loadMore' => 'Charger plus', - 'markerHint' => 'Cliquez un marqueur, puis une carte pour l\'y déplacer.', - 'modePerDeck' => 'Définir par deck', - 'modeGlobal' => 'Définir au global', - 'modeInfoBtn' => 'Quelle est la différence ?', - 'modeInfoTitle' => 'Illustrations par deck ou au global', - 'modePerDeckTitle' => 'Par deck (par défaut)', - 'modePerDeckDesc' => "Le changement des préférences d'une carte n'est pas visible lorsque l'on clique sur une carte. Seuls les jetons sont modifiables sur cette page. Dans le deckbuilder, vous choisissez l'illustration de chaque carte et du héros individuellement pendant la construction du deck.", - 'modeGlobalTitle' => 'Global', - 'modeGlobalDesc' => "Vos préférences d'illustration sont modifiables ici ou en cliquant sur une carte, et s'appliquent automatiquement partout : à l'ouverture d'un deck pour le modifier, à sa duplication, à son import, à sa récupération depuis BGA, et à chaque changement du nombre d'exemplaires d'une carte. Le deckbuilder ne permet plus de changer les illustrations carte par carte, et il n'y a plus de bouton \"appliquer mes préférences\" — c'est toujours appliqué.", - 'modeClose' => 'Fermer', - 'modeSaving' => 'Enregistrement…', + 'keysHint' => 'Cliquez sur une case au-dessus d\'une illustration pour la lui attribuer.', + 'intro' => 'Choisissez l\'illustration de la 1ère, 2ème et 3ème carte de chaque carte : vos arts alternatifs par défaut. Un jeton a une seule illustration, la même dans tous vos decks.', + 'usesNew' => 'Une carte ajoutée à un deck prend', + 'usesAll' => 'Tous vos decks utilisent', + 'results' => 'Cartes', + 'detail_label' => 'Accéder au détail', + 'modeLabel' => 'Utilisation dans vos decks', + 'modePerDeck' => 'Par deck', + 'modeGlobal' => 'Global', + 'modePerDeckDesc' => 'Une carte ajoutée à un deck dans le deck builder prend vos arts par défaut. Chaque deck garde ensuite ses propres illustrations, que vous pouvez changer carte par carte pour ce deck seulement. Board Game Arena montre les illustrations de chaque deck.', + 'modeGlobalDesc' => 'Vos arts par défaut s\'appliquent à tous vos decks, sur ce site et sur Board Game Arena, à chaque ouverture et à chaque modification d\'un deck. Les illustrations ne se changent pas deck par deck.', + 'toGlobalTitle' => 'Passer en mode global ?', + 'toGlobalText' => 'Tous vos decks vont suivre vos arts par défaut. Les illustrations choisies deck par deck seront remplacées.', + 'toGlobalConfirm' => 'Passer en mode global', + 'toPerDeckTitle' => 'Passer en mode par deck ?', + 'toPerDeckText' => 'Vos arts par défaut vont être écrits dans tous vos decks. Chaque deck garde ensuite ses illustrations, que vous pouvez changer dans le deck builder.', + 'toPerDeckConfirm' => 'Passer en mode par deck', + 'cancel' => 'Annuler', + 'progress' => 'Decks mis à jour : %1$s / %2$s', 'modeSaveError' => 'Impossible d\'enregistrer votre choix.', - 'perDeckNotice' => 'Le mode par deck est actif : seuls les jetons sont gérés ici. Les illustrations des cartes normales se choisissent individuellement dans chaque deck, dans le deckbuilder.', ], ][getUiLang()]; @@ -78,8 +94,7 @@ $ownActiveTab = 'alt-arts'; $userId = (int)($_SESSION['user_id'] ?? 0); -$altArtMode = ($ownEnabled && $ownLoggedIn) ? ownGetAltArtPreferenceMode($userId) : 'PerDeck'; -$isGlobalMode = $altArtMode === 'Global'; +$isGlobalMode = $ownEnabled && $ownLoggedIn && ownGetAltArtPreferenceMode($userId) === 'Global'; $cacDir = dirname(__DIR__, 2) . '/core-altered-cards'; $cacAvailable = is_file($cacDir . '/includes/functions.php'); @@ -100,13 +115,6 @@ // the type filter. $raritiesData = array_filter($raritiesData, fn($r) => ($r['gem'] ?? '') !== 'U'); -// Per-deck mode: this page only manages token illustrations — regular card alt-arts are -// chosen per deck in the deckbuilder instead. Enforced again server-side in -// api/alt-art-search.php; this is just what the type-filter row offers. -if (!$isGlobalMode) { - $typesData = array_filter($typesData, fn($code) => strpos($code, 'TOKEN') === 0, ARRAY_FILTER_USE_KEY); -} - $uiLang = getUiLang(); ?>
@@ -130,39 +138,41 @@ .filter-toggle below come from it. --> -
-
- - -
- -
+

- -
- - - + +
+

+
+
+

+ + +
+ +
+
-

+

-
+
diff --git a/plugins/ownership/pages/ownership.php b/plugins/ownership/pages/ownership.php index 112ccb77..2468e7f9 100644 --- a/plugins/ownership/pages/ownership.php +++ b/plugins/ownership/pages/ownership.php @@ -12,8 +12,8 @@ 'boosters_remaining' => '%d remaining', 'history' => 'History', 'history_d' => 'View every transaction that changed your digital ownership.', - 'altArts' => 'Alt Arts BGA', - 'altArts_d' => 'Choose which illustration is used for each copy you play on Board Game Arena.', + 'altArts' => 'Default alt arts', + 'altArts_d' => 'Choose the illustration each copy of a card takes when you add it to a deck.', 'import' => 'Equinox Import', 'import_d' => 'Request and/or import your digital ownership from the Equinox site (a secure site).', 'login_prompt' => 'Log in to manage your digital ownership.', @@ -30,8 +30,8 @@ 'boosters_remaining' => '%d restant(s)', 'history' => 'Historique', 'history_d' => 'Consultez toutes les transactions qui ont modifié vos propriétés.', - 'altArts' => 'Alt Arts BGA', - 'altArts_d' => 'Choisissez l\'illustration utilisée pour chaque exemplaire joué sur Board Game Arena.', + 'altArts' => 'Arts alternatifs par défaut', + 'altArts_d' => 'Choisissez l\'illustration que prend chaque exemplaire d\'une carte quand vous l\'ajoutez à un deck.', 'import' => 'Import Equinox', 'import_d' => 'Demandez et/ou importez vos propriétés numériques du site d\'Equinox (sur un site sécurisé).', 'login_prompt' => 'Connectez-vous pour gérer votre propriété numérique.', diff --git a/plugins/ownership/plugin.json b/plugins/ownership/plugin.json index 36b1b866..6bc9ecad 100644 --- a/plugins/ownership/plugin.json +++ b/plugins/ownership/plugin.json @@ -12,7 +12,7 @@ {"slug": "ownership-collection", "file": "pages/collection.php", "title_en": "Collection", "title_fr": "Collection"}, {"slug": "boosters", "file": "pages/boosters.php", "title_en": "Boosters", "title_fr": "Boosters"}, {"slug": "ownership-history", "file": "pages/history.php", "title_en": "History", "title_fr": "Historique"}, - {"slug": "ownership-alt-arts", "file": "pages/alt-arts.php", "title_en": "Alt Arts BGA", "title_fr": "Alt Arts BGA"} + {"slug": "ownership-alt-arts", "file": "pages/alt-arts.php", "title_en": "Default alt arts", "title_fr": "Arts alternatifs par défaut"} ], "api": [ @@ -22,7 +22,8 @@ {"endpoint": "history-detail", "file": "api/history-detail.php"}, {"endpoint": "alt-art-search", "file": "api/alt-art-search.php"}, {"endpoint": "alt-art-set-preference", "file": "api/alt-art-set-preference.php"}, - {"endpoint": "alt-art-preference-mode", "file": "api/alt-art-preference-mode.php"} + {"endpoint": "alt-art-preference-mode", "file": "api/alt-art-preference-mode.php", "methods": ["GET", "PUT"], "auth": "user"}, + {"endpoint": "alt-art-leave-global", "file": "api/alt-art-leave-global.php", "methods": ["GET", "POST"], "auth": "user"} ], "assets": { diff --git a/plugins/rebuilder/README.md b/plugins/rebuilder/README.md index 896c8cf4..a071e4bc 100644 --- a/plugins/rebuilder/README.md +++ b/plugins/rebuilder/README.md @@ -89,8 +89,8 @@ decks API, opened by id. The local stack's decks API does not have them: their p « Importer » › *Export altered.gg* imports the decks of an altered.gg personal-data export (the Equinox ZIP) into the account, with the behaviour of the site's `equinox-deck-import` plugin: the ZIP is read -in the browser (`decks.csv`, `DecompressionStream`); with the « Global » alt-art preference of the -ownership API (relay `services.ownership`), the user's alt arts replace the exported cards; each deck is +in the browser (`decks.csv`, `DecompressionStream`); with the ownership service (relay `services.ownership`), the +cards and the hero take the user's default alt arts; each deck is created private with `POST /api/decks` through the relay, one per second, and a deck already in the account (same name, same cards) is skipped. A failed deck stops the queue until « Réessayer » (3 attempts, then it is given up); the import can be paused or cancelled. diff --git a/plugins/rebuilder/app/design/COMPONENTS.md b/plugins/rebuilder/app/design/COMPONENTS.md index 73dab62c..b4670e23 100644 --- a/plugins/rebuilder/app/design/COMPONENTS.md +++ b/plugins/rebuilder/app/design/COMPONENTS.md @@ -115,7 +115,8 @@ Classes et états des champs et du contrôle segmenté : `design-system/docs/com | Composant | Entrées | Sorties | Écrans | |---|---|---|---| -| `ac-card-tile` | `card`, `quantity`, `max` ; `blockedReason` (avec `max` à 0 et aucun exemplaire : badge « Interdite » à la place du « + », raison en `title` et en texte masqué, ex. Unique en format No Unique) ; affiche coût main / réserve, nom + type sur bandeau ; anneau `--ac-ring-selected` si `quantity > 0` ; `readonly` (« ×n ») ; `plain` (carte seule, sans « + » ni compteur) ; une référence unique (`…_U_n`) affiche `ac-unique-card` à la place de l'image ; en attendant l'image, un fond gris clair uni (`--ac-color-track`, `ac-card-art` en `neutral`), sans texte ni couleur de faction ; `zoomable` : le visuel est un bouton (curseur loupe, « Agrandir … ») ; `favorite: boolean | null` : étoile en haut à droite (visible au survol, au focus, en tactile, et toujours quand la carte est favorite ; `null` la masque) | `quantityChange`, `zoom` (clic sur le visuel, avec `zoomable`), `favoriteToggle` (clic sur l’étoile) | recherche, uniques, aperçu, zoom d’une carte | +| `ac-card-tile` | `card`, `quantity`, `max` ; `blockedReason` (avec `max` à 0 et aucun exemplaire : badge « Interdite » à la place du « + », raison en `title` et en texte masqué, ex. Unique en format No Unique) ; affiche coût main / réserve, nom + type sur bandeau ; anneau `--ac-ring-selected` si `quantity > 0` ; `readonly` (« ×n ») ; `plain` (carte seule, sans « + » ni compteur) ; une référence unique (`…_U_n`) affiche `ac-unique-card` à la place de l'image ; en attendant l'image, un fond gris clair uni (`--ac-color-track`, `ac-card-art` en `neutral`), sans texte ni couleur de faction ; `zoomable` : le visuel est un bouton (curseur loupe, « Agrandir … ») ; `favorite: boolean | null` : étoile en haut à droite (visible au survol, au focus, en tactile, et toujours quand la carte est favorite ; `null` la masque) ; `illustrations` : pinceau en haut à gauche, toujours visible (carte à plusieurs illustrations dans un deck modifiable) | `quantityChange`, `zoom` (clic sur le visuel, avec `zoomable`), `favoriteToggle` (clic sur l’étoile), `illustrate` (clic sur le pinceau) | recherche, uniques, aperçu, zoom d’une carte | +| `ac-card-pile` | `card`, `quantity` (cartes empilées, une par exemplaire jusqu’à 3, « ×n ») ; `prints` (illustration de chaque exemplaire, celle de devant en premier : les illustrations d’une carte sur une pile) ; `max` ; `readonly` (sans stepper) ; `issues` (marqueur d’alerte, raisons en `title`) ; `illustrations` : pinceau en haut à droite de la carte de devant, toujours visible | `quantityChange` (stepper au survol, au focus, toujours en tactile), `zoom` (clic sur la pile), `illustrate` (clic sur le pinceau) | aperçu du deck (à partir de 768 px) | | `ac-unique-card` | `card` (réponse `/api/cards`, textes en chaîne ou en table de langues), `eager` ; face d'une Unique dessinée à partir de ses données, car aucune image par Unique n'est publiée : illustration Unique du CDN (`cards/assets/{SET}/{carte}_U.webp`, repli sans cadre puis dos de carte), coûts main / réserve, puissances Forêt / Montagne / Océan (icônes `assets/biome/`), texte (`{J}` `{R}` `{H}` `{D}` `{T}` en pastilles, `[mot-clé]` en gras, capacité de soutien sous un filet), numéro de collection en pied ; couleur de faction via `--ac-faction-*` ; tailles en `cqw`, lisibles comme une image de carte à la même largeur ; le cadre et le texte passent au-dessus de l'illustration ; sans coûts (ligne de deck invité rechargée) : illustration et nom seuls. L'aperçu d'un deck charge le texte imprimé (`mainEffect` / `echoEffect`) : conservé sur la ligne invité, sinon `POST /api/cards/batch` | — | tuiles Uniques (recherche, aperçu du deck) | | `ac-deck-row` | `card`, `quantity`, `max`, `readonly` (consultation : « ×3 » + coûts, sans stepper), `plain` (rareté, nom, coûts, sans quantité), `issues` (règles enfreintes par la ligne : nom en `--ac-color-danger` et icône `circle-alert` dont `aria-label` / `title` donnent les raisons), `blockedReason` (avec `max` à 0 et aucun exemplaire : badge « Interdite » à la place du stepper, raison en `title` et en texte masqué) | `quantityChange` | panneau deck, Deck mobile, decklist | | `ac-deck-section` | `title` (Personnages, Sorts…), `count`, `collapsible` | — | panneau deck, aperçu, cartes | diff --git a/plugins/rebuilder/app/docs/backend-api.md b/plugins/rebuilder/app/docs/backend-api.md index fbaa86be..861b708b 100644 --- a/plugins/rebuilder/app/docs/backend-api.md +++ b/plugins/rebuilder/app/docs/backend-api.md @@ -135,10 +135,12 @@ Cookie + CSRF. Not required for the first milestone (this app is a parallel clie | `ownership-search` | Digital ownership scope | | `favorites-search` / `favorites-toggle` | Per-user favorites (local PHP DB) | | `deck-add-card` | Incremental add: GET deck + PATCH full `deckCards` | -| `deck-alt-arts` | Alt-art options for a card in a deck | +| `deck-alt-arts` | Families of illustrations of deck and search references: prints, copies owned, the player's default alt arts (`core/alt-art-defaults.ts`) | | `playset` / `playset-cards` | Physical playset dashboard | -Ownership plugin (when enabled): `/papi/ownership/alt-art-search`, `alt-art-set-preference`. +Ownership plugin (when enabled): `/papi/ownership/alt-art-search` (token illustrations), the alt-art mode is read through the relay (`GET /api/alt-arts/preference-mode`: in « Global », the deck follows the default alt arts and has no brush). Token illustrations are saved through the relay (`PUT /api/alt-arts/preferences`). + +Rebuilder plugin: `/papi/rebuilder/deck-alt-arts` keeps the brush's choices for a deck of the signed-in player (table `{rebuilder_deck_alt_arts}`, `sql/migration_009`), because the decks API stores only the deck's references (no order, nothing past its copies). `GET ?deck=` → `{"families": {"::": ["<1st card>", "<2nd>", "<3rd>"]}}`; `POST {deck, family, cards}` saves a family's three cards (`cards: null`: it follows the default alt arts again); `POST {deck}` drops every family of the deck. No row for a family whose three cards take the plain print; a deck deleted through the services relay or the site's deck pages loses its rows (`altArtForgetDeck`, `includes/func.alt-arts.php`). The player's default alt arts stay in the ownership service. ## Local stack diff --git a/plugins/rebuilder/app/docs/legacy-parity.md b/plugins/rebuilder/app/docs/legacy-parity.md index 84e1ecdb..92394deb 100644 --- a/plugins/rebuilder/app/docs/legacy-parity.md +++ b/plugins/rebuilder/app/docs/legacy-parity.md @@ -41,8 +41,8 @@ Légende : ✅ fait · 🟡 partiel · ❌ manquant. « Fait dans #106 » = corr | Stats : répartition par type, puissances moyennes (le plugin affiche des totaux) | ❌ écarté volontairement (jugé inutile) | | Main de test en mode jeu (mana, plateau, défausse, glisser-déposer) | ✅ (glisser-déposer du CDK, menu d’actions au clic ; masqué sur mobile) | | Calculateurs : vignettes et groupes par type dans le choix des cartes | ✅ | -| Duplication avec les illustrations préférées (mode Global) | ✅ | -| Étoile favori sur les cartes, widget possession / tilt dans le zoom | ✅ (favoris du site ; widget et tilt en mode Global, comme le site) | +| Duplication avec les illustrations préférées | ✅ (le deck d’un autre joueur prend les arts par défaut ; une copie de son propre deck garde ses illustrations) | +| Étoile favori sur les cartes, widget possession / tilt dans le zoom | ✅ (favoris du site) ; pas de widget ni de tilt du mode Global : les arts par défaut se choisissent sur la page du site | | Groupes fins (Repères, Permanents d'expédition, Jetons) ; faction et puissances de toutes les cartes dans la decklist | 🟡 | ## Éditeur @@ -64,7 +64,7 @@ Légende : ✅ fait · 🟡 partiel · ❌ manquant. « Fait dans #106 » = corr | Onglets Collection physique, Possession numérique, Favoris | ✅ (endpoints du plugin core-altered-cards ; collection et possession demandent leurs API) | | Favoris : Uniques (rareté U) et filtre « Légales en » par défaut | ✅ (ajout Re:Builder ; liste Frontier lue dans `gameplayFormat`) | | Zoom d'une carte avec quantité et lien vers la fiche | ✅ (recherche et aperçu) | -| Illustrations : choix par carte, préférence Global, illustrations des jetons, avertissements de possession ; héros alt-art / sérialisés | ✅ (service ownership requis, sauf les héros alt-art) | +| Illustrations : choix par carte, préférence Global, illustrations des jetons, avertissements de possession ; héros alt-art / sérialisés | ✅ Mode par deck : une carte ajoutée prend l’art par défaut de son exemplaire, le pinceau change les illustrations d’une carte pour ce deck, « Arts par défaut » réapplique les défauts. Mode Global : le deck prend les arts par défaut à l’ouverture et après chaque changement, sans pinceau (service ownership requis, sauf les héros alt-art) | | Toast « Annuler » après un ajout ou un retrait | ✅ | | Bandeau d'état avec progression vers le minimum | ❌ écarté volontairement (jugé inutile) | | Main de départ dans l'éditeur | ✅ (vue « Main de départ », `/edit/main`) | diff --git a/plugins/rebuilder/app/src/app/core/alt-art-defaults.spec.ts b/plugins/rebuilder/app/src/app/core/alt-art-defaults.spec.ts new file mode 100644 index 00000000..9fcbd62b --- /dev/null +++ b/plugins/rebuilder/app/src/app/core/alt-art-defaults.spec.ts @@ -0,0 +1,159 @@ +import { addedCopyPrint, canRank, defaultPrints, familyPrints, linesWithDefaults, rankPrints, removedCopyPrint, sameCopies, slotChoices, slotDefaults, withFamilyPrints, withRanks } from './alt-art-defaults'; +import type { HydratedLine } from './models'; +import type { AltArtChoice } from './ownership-api.service'; + +const BASE = 'ALT_CORE_B_AX_09_C'; +const MUSUBI = 'ALT_MUSUBI_B_AX_09_C'; +const ALT = 'ALT_CORE_A_AX_09_C'; + +/** Fée Clochette: the plain print, an unlimited MUSUBI print, an alt art owned `owned` times; `slots` are the defaults. */ +function fee(slots: string[], owned = 2): AltArtChoice { + return { + family: { familyId: 304, faction: 'AX', rarity: 'C' }, + options: { + options: [ + { reference: BASE, ownedQuantity: null }, + { reference: MUSUBI, ownedQuantity: null }, + { reference: ALT, ownedQuantity: owned }, + ], + slots: slots.map((reference, i) => ({ slotIndex: i + 1, reference, isExplicitChoice: reference !== BASE })), + }, + }; +} + +const line = (reference: string, quantity: number): HydratedLine => ({ card: { reference, name: 'Fée Clochette' }, quantity }); + +describe('defaultPrints', () => { + it('gives copy i the default of slot i, the copies past the last slot the last one', () => { + expect(defaultPrints(fee([MUSUBI, ALT, BASE]), 3)).toEqual([MUSUBI, ALT, BASE]); + expect(defaultPrints(fee([MUSUBI]), 3)).toEqual([MUSUBI, MUSUBI, MUSUBI]); + }); + + it('falls back to the plain print once the owned copies are used', () => { + expect(defaultPrints(fee([ALT, ALT, ALT], 2), 3)).toEqual([ALT, ALT, BASE]); + expect(defaultPrints(fee([ALT, ALT, ALT], 0), 2)).toEqual([BASE, BASE]); + }); + + it('sorts the slots by their index (the service counts from 1, the mock from 0)', () => { + const choice = fee([]); + choice.options.slots = [ + { slotIndex: 2, reference: ALT }, + { slotIndex: 1, reference: MUSUBI }, + ]; + expect(defaultPrints(choice, 2)).toEqual([MUSUBI, ALT]); + }); +}); + +describe('choices (brush)', () => { + it('gives three choices from the slots, the last one repeated, the plain print without any', () => { + expect(rankPrints(fee([MUSUBI, ALT, BASE]))).toEqual([MUSUBI, ALT, BASE]); + expect(rankPrints(fee([ALT]))).toEqual([ALT, ALT, ALT]); + expect(rankPrints(fee([]))).toEqual([BASE, BASE, BASE]); + }); + + it('turns the choices into the slots of the defaults', () => { + const choice = withRanks(fee([]), [ALT, MUSUBI, ALT]); + expect(rankPrints(choice)).toEqual([ALT, MUSUBI, ALT]); + expect(defaultPrints(choice, 2)).toEqual([ALT, MUSUBI]); + }); + + it('lets a choice take an alt art while the other choices hold fewer than the copies owned', () => { + const choice = fee([ALT, BASE, BASE], 1); + expect(canRank(choice, rankPrints(choice), 1, ALT)).toBe(false); + expect(canRank(choice, rankPrints(choice), 0, ALT)).toBe(true); + expect(canRank(choice, rankPrints(choice), 1, MUSUBI)).toBe(true); + expect(canRank(fee([], 0), [BASE, BASE, BASE], 0, ALT)).toBe(false); + }); +}); + +describe('slotDefaults', () => { + it('gives a free copy its slot’s default, the copies chosen in the deck taking the owned copies first', () => { + expect(slotDefaults(fee([ALT, ALT, MUSUBI], 2), [null, null, null])).toEqual([ALT, ALT, MUSUBI]); + expect(slotDefaults(fee([ALT, ALT, MUSUBI], 2), [BASE, null, null])).toEqual([BASE, ALT, MUSUBI]); + expect(slotDefaults(fee([ALT, ALT, ALT], 2), [null, ALT, null])).toEqual([ALT, ALT, BASE]); + expect(slotDefaults(fee([ALT, ALT, ALT], 1), [null, null, ALT])).toEqual([BASE, BASE, ALT]); + }); +}); + +describe('slotChoices', () => { + const back = (choice: AltArtChoice, prints: string[]) => slotDefaults(choice, slotChoices(choice, prints)); + + it('leaves free the copies on their default, the other prints chosen and first', () => { + expect(slotChoices(fee([ALT, ALT, MUSUBI]), [ALT, ALT, MUSUBI])).toEqual([null, null, null]); + expect(slotChoices(fee([ALT, ALT, MUSUBI]), [MUSUBI, ALT, BASE])).toEqual([BASE, null, null]); + expect(slotChoices(fee([BASE, BASE, BASE]), [BASE, ALT, BASE])).toEqual([ALT, null, null]); + expect(slotChoices(fee([ALT, MUSUBI, MUSUBI]), [BASE, BASE, BASE])).toEqual([BASE, BASE, BASE]); + }); + + it('gives back the deck’s copies, an owned print taken by a chosen copy included', () => { + const choice = fee([ALT, ALT, ALT], 1); + expect(slotChoices(choice, [BASE, BASE, BASE])).toEqual([BASE, BASE, BASE]); + for (const prints of [[BASE, BASE, BASE], [ALT, BASE, BASE], [MUSUBI, BASE, ALT], [MUSUBI, MUSUBI, MUSUBI], [ALT, ALT, BASE]]) { + expect(sameCopies(back(choice, prints), prints)).toBe(true); + } + }); +}); + +describe('addedCopyPrint', () => { + it('gives an added copy the next default the copies lack', () => { + const choice = fee([MUSUBI, ALT, BASE]); + expect(addedCopyPrint(choice, [])).toBe(MUSUBI); + expect(addedCopyPrint(choice, [MUSUBI])).toBe(ALT); + expect(addedCopyPrint(choice, [MUSUBI, ALT])).toBe(BASE); + }); + + it('keeps a print chosen in the deck: the next copy takes the first default not used yet', () => { + // The deck put its 1st copy on the alt art: the next copy takes the MUSUBI print of the 1st default. + expect(addedCopyPrint(fee([MUSUBI, ALT, BASE]), [ALT])).toBe(MUSUBI); + }); + + it('never gives more copies of a print than owned', () => { + // 2 alt arts owned, both in the deck already: the 3rd default (the alt art) falls back to the plain print. + expect(addedCopyPrint(fee([ALT, ALT, ALT], 2), [ALT, ALT])).toBe(BASE); + }); +}); + +describe('removedCopyPrint', () => { + it('removes the last copy by the defaults, then the plain print, keeping the prints chosen in the deck', () => { + const choice = fee([MUSUBI, ALT, BASE]); + expect(removedCopyPrint(choice, [MUSUBI, ALT, BASE])).toBe(BASE); + expect(removedCopyPrint(choice, [MUSUBI, ALT])).toBe(ALT); + // The deck chose the alt art for a copy: the copy on the default goes first. + expect(removedCopyPrint(fee([MUSUBI, MUSUBI, BASE]), [ALT, MUSUBI])).toBe(MUSUBI); + }); +}); + +describe('family lines', () => { + const members = new Set([BASE, MUSUBI]); + + it('reads the copies of a family and writes them back at the place of its first line', () => { + const lines = [line('OTHER', 1), line(BASE, 2), line('LAST', 1), line(MUSUBI, 1)]; + expect(familyPrints(lines, members)).toEqual([BASE, BASE, MUSUBI]); + const next = withFamilyPrints(lines, members, lines[1].card, [ALT, MUSUBI, MUSUBI]); + expect(next.map((l) => [l.card.reference, l.quantity])).toEqual([ + ['OTHER', 1], + [ALT, 1], + [MUSUBI, 2], + ['LAST', 1], + ]); + // A new print copies the card of the family, with its own reference. + expect(next[1].card.name).toBe('Fée Clochette'); + }); + + it('applies the defaults to every family, `null` when the deck has them already', () => { + const choice = fee([MUSUBI, MUSUBI, BASE]); + const choices = { [BASE]: choice, [MUSUBI]: choice }; + const out = linesWithDefaults([line(BASE, 3), line('OTHER', 2)], choices); + expect(out?.map((l) => [l.card.reference, l.quantity])).toEqual([ + [MUSUBI, 2], + [BASE, 1], + ['OTHER', 2], + ]); + expect(linesWithDefaults(out ?? [], choices)).toBeNull(); + }); + + it('compares copies whatever their order', () => { + expect(sameCopies([BASE, MUSUBI], [MUSUBI, BASE])).toBe(true); + expect(sameCopies([BASE, BASE], [MUSUBI, BASE])).toBe(false); + }); +}); diff --git a/plugins/rebuilder/app/src/app/core/alt-art-defaults.ts b/plugins/rebuilder/app/src/app/core/alt-art-defaults.ts new file mode 100644 index 00000000..4e435607 --- /dev/null +++ b/plugins/rebuilder/app/src/app/core/alt-art-defaults.ts @@ -0,0 +1,203 @@ +import type { HydratedLine } from './models'; +import { familyKey, type AltArtChoice } from './ownership-api.service'; + +/** + * Default alt arts. The player picks once, on the site's « Arts alternatifs par défaut » page, which print each copy of + * a card takes (1st, 2nd, 3rd copy: the ownership service's slots); a deck only stores the prints it uses. The defaults + * apply when a card enters a deck and on « Appliquer les arts par défaut »; Board Game Arena plays the deck's prints + * (the base art for copies the player does not own). + */ + +/** The family's plain illustration, everyone's: the service lists it first. */ +export function basePrint(choice: AltArtChoice): string { + return choice.options.options[0]?.reference ?? ''; +} + +/** Copies the player owns of `reference`; `null` for unlimited (a print the service does not list, as a reprint). */ +function ownedOf(choice: AltArtChoice, reference: string): number | null { + const option = choice.options.options.find((o) => o.reference === reference); + return option ? option.ownedQuantity : null; +} + +/** The family's slots, 1st copy first (the service numbers them from 1, the stack's mock from 0). */ +export function slotPrints(choice: AltArtChoice): string[] { + return [...choice.options.slots].sort((a, b) => a.slotIndex - b.slotIndex).map((s) => s.reference); +} + +/** Choices of the brush (1st, 2nd, 3rd): one a copy slot of the ownership service. */ +const RANK_COUNT = 3; + +/** + * The player's default alt arts of the family as choices, 1st first: its slots (the last one repeated), the base print + * without any. + */ +export function rankPrints(choice: AltArtChoice): string[] { + const slots = slotPrints(choice); + const base = basePrint(choice); + return Array.from({ length: RANK_COUNT }, (_, i) => (slots.length ? slots[Math.min(i, slots.length - 1)] : base)); +} + +/** `choice` with the default alt arts `ranks` (1st choice first). */ +export function withRanks(choice: AltArtChoice, ranks: readonly string[]): AltArtChoice { + const base = basePrint(choice); + const slots = ranks.map((reference, i) => ({ slotIndex: i + 1, reference, isExplicitChoice: reference !== base })); + return { ...choice, options: { ...choice.options, slots } }; +} + +/** Choice `rank` can take `reference`: the player owns more copies of it than the other choices hold (`null`: unlimited). */ +export function canRank(choice: AltArtChoice, ranks: readonly string[], rank: number, reference: string): boolean { + const owned = ownedOf(choice, reference); + return owned === null || ranks.filter((r, i) => i !== rank && r === reference).length < owned; +} + +/** + * The prints of `count` copies by the player's defaults: copy i takes slot i (the copies past the last slot take the + * last one), or the base print once the copies owned of that print are used. + */ +export function defaultPrints(choice: AltArtChoice, count: number): string[] { + return slotDefaults(choice, Array(count).fill(null)); +} + +/** + * The prints of copies whose illustration is chosen in the deck (`chosen[i]`) or left to the defaults (`null`): a free + * copy i takes slot i (the copies past the last slot take the last one), or the base print once the copies owned of + * that print are used, the chosen copies first. + */ +export function slotDefaults(choice: AltArtChoice, chosen: readonly (string | null)[]): string[] { + const slots = slotPrints(choice); + const base = basePrint(choice); + const used = counts(chosen.filter((c): c is string => c !== null)); + return chosen.map((c, i) => { + if (c !== null) return c; + const wanted = slots.length ? slots[Math.min(i, slots.length - 1)] : base; + const owned = ownedOf(choice, wanted); + const print = owned === null || (used.get(wanted) ?? 0) < owned ? wanted : base; + used.set(print, (used.get(print) ?? 0) + 1); + return print; + }); +} + +/** + * The deck's copies of a family (`prints`) as copies chosen in the deck or left to the defaults (`null`), so that + * `slotDefaults` gives the same prints. The deck stores no order: the prints its defaults do not explain are chosen and + * come first (copy 1 is the deck's own choice, the front of its pile), the other copies are free when their slot's + * default matches, chosen otherwise. + */ +export function slotChoices(choice: AltArtChoice, prints: readonly string[]): (string | null)[] { + const left = counts(prints); + for (const d of defaultPrints(choice, prints.length)) { + const n = left.get(d) ?? 0; + if (n) left.set(d, n - 1); + } + const own = [...left].flatMap(([print, n]) => Array(n).fill(print)); + const chosen: (string | null)[] = [...own, ...Array(prints.length - own.length).fill(null)]; + // A free copy whose default is not one of the deck's prints left (an owned print the chosen copies took): chosen. + for (;;) { + const missing = counts(prints); + const shown = slotDefaults(choice, chosen); + for (const s of shown) missing.set(s, (missing.get(s) ?? 0) - 1); + if ([...missing.values()].every((n) => n === 0)) return chosen; + const extra = shown.findIndex((s, i) => chosen[i] === null && (missing.get(s) ?? 0) < 0); + const lacking = [...missing].find(([, n]) => n > 0)?.[0]; + if (extra === -1 || lacking === undefined) return [...prints]; + chosen[extra] = lacking; + } +} + +/** The family's copies (`prints`) in the brush's order: copy 1 first, the copies on their default alt art on their slot. */ +export function familyCopies(choice: AltArtChoice, prints: readonly string[]): string[] { + return slotDefaults(choice, slotChoices(choice, prints)); +} + +/** + * The print of a copy added to a family whose copies show `current`: the first default (by slot) the copies lack, + * so that a copy chosen in the deck stays and the next copies follow the defaults; the base print otherwise. + */ +export function addedCopyPrint(choice: AltArtChoice, current: readonly string[]): string { + const left = counts(current); + for (const print of defaultPrints(choice, current.length + 1)) { + const n = left.get(print) ?? 0; + if (n === 0) return print; + left.set(print, n - 1); + } + return basePrint(choice); +} + +/** + * The print of the copy removed from a family whose copies show `current`: the last copy's default when the deck has + * it, else the base print, else the last copy; a print chosen in the deck goes last. + */ +export function removedCopyPrint(choice: AltArtChoice, current: readonly string[]): string { + const last = defaultPrints(choice, current.length).at(-1); + if (last && current.includes(last)) return last; + const base = basePrint(choice); + return current.includes(base) ? base : (current.at(-1) ?? base); +} + +/** Copies by print, in the order of `prints`. */ +export function counts(prints: readonly string[]): Map { + const out = new Map(); + for (const p of prints) out.set(p, (out.get(p) ?? 0) + 1); + return out; +} + +/** A family's copies, one print a copy: its lines in deck order. */ +export function familyPrints(lines: readonly HydratedLine[], members: ReadonlySet): string[] { + return lines.filter((l) => members.has(l.card.reference)).flatMap((l) => Array(l.quantity).fill(l.card.reference)); +} + +/** + * `lines` with the family's lines (references in `members`) replaced by `prints`, at the place of its first line. The + * new lines copy the card of `template`, with their reference. + */ +export function withFamilyPrints(lines: readonly HydratedLine[], members: ReadonlySet, template: HydratedLine['card'], prints: readonly string[]): HydratedLine[] { + const replaced = [...counts(prints)].map(([reference, quantity]) => { + const existing = lines.find((l) => l.card.reference === reference); + // Another print: the template's card without its image (the art follows the reference). + return { card: existing?.card ?? (reference === template.reference ? template : { ...template, reference, imagePath: undefined }), quantity }; + }); + const at = lines.findIndex((l) => members.has(l.card.reference)); + const rest = lines.filter((l) => !members.has(l.card.reference)); + const index = at === -1 ? rest.length : lines.slice(0, at).filter((l) => !members.has(l.card.reference)).length; + return [...rest.slice(0, index), ...replaced, ...rest.slice(index)]; +} + +/** The deck's references grouped by family (multi-art families only), by family key. */ +function familiesOf(references: Iterable, choices: Readonly>): Map> { + const out = new Map>(); + for (const ref of references) { + const choice = choices[ref]; + if (!choice) continue; + const key = familyKey(choice.family); + const set = out.get(key) ?? new Set(); + set.add(ref); + out.set(key, set); + } + return out; +} + +/** + * « Appliquer les arts par défaut »: every multi-art family of `lines` takes the player's defaults for its copies + * (`defaultPrints`). `null` when every line already matches. + */ +export function linesWithDefaults(lines: readonly HydratedLine[], choices: Readonly>): HydratedLine[] | null { + let out = [...lines]; + let changed = false; + for (const members of familiesOf(lines.map((l) => l.card.reference), choices).values()) { + const current = familyPrints(out, members); + const choice = choices[[...members][0]]; + const next = defaultPrints(choice, current.length); + if (sameCopies(current, next)) continue; + changed = true; + const template = out.find((l) => members.has(l.card.reference))!.card; + out = withFamilyPrints(out, members, template, next); + } + return changed ? out : null; +} + +/** Same copies of each print, in any order. */ +export function sameCopies(a: readonly string[], b: readonly string[]): boolean { + if (a.length !== b.length) return false; + const left = counts(a); + return [...counts(b)].every(([print, n]) => left.get(print) === n); +} diff --git a/plugins/rebuilder/app/src/app/core/deck-alt-arts-api.service.ts b/plugins/rebuilder/app/src/app/core/deck-alt-arts-api.service.ts new file mode 100644 index 00000000..184e8988 --- /dev/null +++ b/plugins/rebuilder/app/src/app/core/deck-alt-arts-api.service.ts @@ -0,0 +1,33 @@ +import { HttpClient } from '@angular/common/http'; +import { Service, inject } from '@angular/core'; +import { type Observable, catchError, map, of } from 'rxjs'; +import { environment } from '../../environments/environment'; + +/** + * The brush's choices for a deck (site's `deck-alt-arts`, plugin rebuilder): the illustration of the 1st, 2nd and 3rd + * card of each multi-art card, in order, by family key (`familyKey`). The decks API keeps only the deck's references, + * with no order and nothing past its copies; the default alt arts stay in the ownership service. + */ +@Service() +export class DeckAltArtsApiService { + private readonly http = inject(HttpClient); + private readonly url = `${environment.pluginApiUrl.replace(/\/?$/, '/')}deck-alt-arts`; + + /** The deck's choices, by family key; none on an error (the deck's references then give them). */ + load(deckId: string): Observable> { + return this.http.get<{ families?: Record }>(this.url, { params: { deck: deckId } }).pipe( + map((res) => res?.families ?? {}), + catchError(() => of({})), + ); + } + + /** The cards of family `family` in the deck; `null`: the family follows the default alt arts again. Errors reach the caller. */ + save(deckId: string, family: string, cards: readonly string[] | null): Observable { + return this.http.post(this.url, { deck: deckId, family, cards }); + } + + /** Every card of the deck follows the default alt arts again (« Appliquer les arts par défaut »). */ + clear(deckId: string): Observable { + return this.http.post(this.url, { deck: deckId }).pipe(catchError(() => of(undefined))); + } +} diff --git a/plugins/rebuilder/app/src/app/core/deck-store.spec.ts b/plugins/rebuilder/app/src/app/core/deck-store.spec.ts index c9ad088a..bc4b7978 100644 --- a/plugins/rebuilder/app/src/app/core/deck-store.spec.ts +++ b/plugins/rebuilder/app/src/app/core/deck-store.spec.ts @@ -6,9 +6,9 @@ import { of, type Observable } from 'rxjs'; import { AuthSession } from './auth-session'; import { CardsApiService } from './cards-api.service'; import { DeckCreateFailurePrompt, type DeckCreateFailureChoice } from './deck-create-failure'; -import { DeckStore, distributeSlots, swapLines } from './deck-store'; +import { DeckStore, cardsWithDefaults } from './deck-store'; import { GUEST_DECKS_KEY, GuestDeckService } from './guest-deck.service'; -import { OwnershipApiService, type CardQuantity } from './ownership-api.service'; +import { OwnershipApiService, type AltArtChoice } from './ownership-api.service'; import type { Card, Deck } from './models'; import { localizedText } from './models'; @@ -719,25 +719,34 @@ describe('GuestDeckService', () => { }); }); -describe('DeckStore (signed in, « Global » alt arts)', () => { - it('duplicates with the preferred illustrations, as the site does', async () => { +describe('DeckStore (signed in, default alt arts)', () => { + const martengale: AltArtChoice = { + family: { familyId: 9, faction: 'LY', rarity: 'C' }, + options: { + options: [ + { reference: 'ALT_CORE_B_LY_04_C', ownedQuantity: null }, + { reference: 'ALT_CORE_A_LY_04_C', ownedQuantity: 3 }, + ], + slots: [1, 2, 3].map((slotIndex) => ({ slotIndex, reference: 'ALT_CORE_A_LY_04_C' })), + }, + }; + + function setup(mine: string[]) { localStorage.clear(); TestBed.configureTestingModule({ providers: [ provideHttpClient(), provideHttpClientTesting(), { provide: AuthSession, useValue: new SignedIn() }, - { - provide: OwnershipApiService, - useValue: { - globalAltArts: () => of(true), - applyAltArts: (cards: CardQuantity[]) => of(cards.map((c) => (c.cardReference === 'ALT_CORE_B_LY_04_C' ? { ...c, cardReference: 'ALT_CORE_A_LY_04_C' } : c))), - }, - }, + { provide: OwnershipApiService, useValue: { altArtChoices: (refs: string[]) => of(refs.includes('ALT_CORE_B_LY_04_C') ? { ALT_CORE_B_LY_04_C: martengale } : {}) } }, ], }); const store = TestBed.inject(DeckStore); const http = TestBed.inject(HttpTestingController); + return { store, http, mine: mine.map((id) => ({ id })) }; + } + + async function open(store: DeckStore, http: HttpTestingController, mine: { id: string }[]) { store.load('source'); TestBed.tick(); http.expectOne((r) => r.url.endsWith('/api/decks/source')).flush({ @@ -751,34 +760,63 @@ describe('DeckStore (signed in, « Global » alt arts)', () => { }); await Promise.resolve(); TestBed.tick(); - http.expectOne((r) => r.method === 'GET' && r.url.endsWith('/api/decks')).flush([]); + http.expectOne((r) => r.method === 'GET' && r.url.endsWith('/api/decks')).flush(mine); await settle(); + } + + it('copies someone else’s deck with the user’s default alt arts', async () => { + const { store, http, mine } = setup([]); + await open(store, http, mine); store.duplicate('Copie').subscribe(); const req = http.expectOne((r) => r.method === 'POST' && r.url.endsWith('/api/decks')); - expect(req.request.body.deckCards).toEqual([ - { cardReference: 'ALT_CORE_B_LY_03_C', quantity: 1 }, - { cardReference: 'ALT_CORE_A_LY_04_C', quantity: 2 }, + expect(req.request.body.deckCards.map((c: { cardReference: string; quantity: number }) => [c.cardReference, c.quantity])).toEqual([ + ['ALT_CORE_B_LY_03_C', 1], + ['ALT_CORE_A_LY_04_C', 2], ]); req.flush({ id: 'copy' }); http.verify(); }); -}); -describe('illustrations of the deck lines', () => { - const line = (reference: string, quantity: number) => ({ card: { reference, name: 'X' }, quantity }); + it('copies the user’s own deck with its illustrations', async () => { + const { store, http, mine } = setup(['source']); + await open(store, http, mine); + store.duplicate('Copie').subscribe(); + const req = http.expectOne((r) => r.method === 'POST' && r.url.endsWith('/api/decks')); + expect(req.request.body.deckCards.map((c: { cardReference: string }) => c.cardReference)).toEqual(['ALT_CORE_B_LY_03_C', 'ALT_CORE_B_LY_04_C']); + req.flush({ id: 'copy' }); + http.verify(); + }); - it('swaps a card for another print, merged with an existing line', () => { - expect(swapLines([line('A_B', 2), line('C', 1)], 'A_B', 'A_P').map((l) => [l.card.reference, l.quantity])).toEqual([['C', 1], ['A_P', 2]]); - expect(swapLines([line('A_B', 2), line('A_P', 1)], 'A_B', 'A_P').map((l) => [l.card.reference, l.quantity])).toEqual([['A_P', 3]]); + it('adds and removes the copies of a family by the defaults, and applies them to the whole deck', async () => { + const { store, http, mine } = setup(['source']); + await open(store, http, mine); + const card = store.lines().find((l) => l.card.reference === 'ALT_CORE_B_LY_04_C')!.card; + const members = new Set(['ALT_CORE_B_LY_04_C', 'ALT_CORE_A_LY_04_C']); + // 2 plain copies in the deck: the 3rd copy takes the 3rd default. + store.setFamilyQuantity(card, martengale, members, 3); + expect(store.lines().map((l) => [l.card.reference, l.quantity])).toEqual([ + ['ALT_CORE_B_LY_04_C', 2], + ['ALT_CORE_A_LY_04_C', 1], + ]); + expect(store.lastChange()).toMatchObject({ card: { reference: 'ALT_CORE_A_LY_04_C' }, quantity: 1, delta: 1 }); + expect(store.applyAltArtDefaults({ ALT_CORE_B_LY_04_C: martengale, ALT_CORE_A_LY_04_C: martengale })).toBe(true); + expect(store.lines().map((l) => [l.card.reference, l.quantity])).toEqual([['ALT_CORE_A_LY_04_C', 3]]); + store.setFamilyQuantity(card, martengale, members, 1); + expect(store.lines().map((l) => [l.card.reference, l.quantity])).toEqual([['ALT_CORE_A_LY_04_C', 1]]); + store.flush(); + http.match(() => true); }); +}); - it('spreads the copies of a family over the slots (Global mode)', () => { - const slots = new Map([ - ['A_B', { key: 'f', slots: ['A_P', 'A_B'] }], - ['A_P', { key: 'f', slots: ['A_P', 'A_B'] }], +describe('cardsWithDefaults', () => { + it('rewrites deck lines with the defaults, a new print copying its family’s line', () => { + const choice: AltArtChoice = { + family: { familyId: 1, faction: 'AX', rarity: 'C' }, + options: { options: [{ reference: 'ALT_B', ownedQuantity: null }, { reference: 'ALT_A', ownedQuantity: 1 }], slots: [{ slotIndex: 1, reference: 'ALT_A' }] }, + }; + expect(cardsWithDefaults([{ cardReference: 'ALT_B', quantity: 2, name: 'X' }], { ALT_B: choice })).toEqual([ + { cardReference: 'ALT_A', quantity: 1, name: 'X' }, + { cardReference: 'ALT_B', quantity: 1, name: 'X' }, ]); - const out = distributeSlots([line('A_B', 3), line('C', 1)], slots); - expect(out?.map((l) => [l.card.reference, l.quantity])).toEqual([['C', 1], ['A_P', 1], ['A_B', 2]]); - expect(distributeSlots(out ?? [], slots)).toBeNull(); }); }); diff --git a/plugins/rebuilder/app/src/app/core/deck-store.ts b/plugins/rebuilder/app/src/app/core/deck-store.ts index 6d577a1c..cf623d21 100644 --- a/plugins/rebuilder/app/src/app/core/deck-store.ts +++ b/plugins/rebuilder/app/src/app/core/deck-store.ts @@ -13,7 +13,8 @@ import { formatInfo } from './formats'; import { cardToLine, deckStats, groupLines, heroOf, isHeroLine, lineToCard, mergeUniqueFace, uniqueNeedsPrintedEffect } from './deck-view'; import { DecksApiService } from './decks-api.service'; import { GuestDeckService } from './guest-deck.service'; -import { OwnershipApiService } from './ownership-api.service'; +import { OwnershipApiService, type AltArtChoice } from './ownership-api.service'; +import { addedCopyPrint, defaultPrints, familyPrints, linesWithDefaults, removedCopyPrint, sameCopies, withFamilyPrints } from './alt-art-defaults'; import { type Card, type Deck, @@ -252,7 +253,8 @@ export class DeckStore { * whether to try again or keep the deck in this browser; cancelling completes without a deck. */ createDeck(input: NewDeckInput): Observable { - if (!this.auth.isLoggedIn()) return of(this.create(input)); + if (!this.auth.isLoggedIn()) return of(this.create({ ...input, hero: plainHero(input.hero) })); + if (input.hero.defaultArt) return this.heroWithDefaultArt(input.hero).pipe(switchMap((hero) => this.createDeck({ ...input, hero }))); const description = input.description?.trim() ?? ''; const body: DeckWrite = { name: input.name.trim() || $localize`:@@core.deck.defaultName:Nouveau deck`, @@ -338,25 +340,53 @@ export class DeckStore { } /** - * « Choisir une illustration »: the copies of `card` take the print `reference` (another illustration of the same - * card), merged with that print's line when the deck has one already. + * The copies of a family (lines whose reference is in `members`) become `prints`, one print a copy: the brush of a + * card. New lines copy `template`. */ - swapReference(card: Card, reference: string): void { - if (!this.editable() || card.reference === reference) return; - this.lines.set(swapLines(this.lines(), card.reference, reference)); + setFamilyPrints(members: ReadonlySet, template: Card, prints: readonly string[]): void { + if (!this.editable()) return; + const current = familyPrints(this.lines(), members); + if (sameCopies(current, prints)) return; + this.lines.set(withFamilyPrints(this.lines(), members, template, prints)); + this.touch(); + } + + /** + * A family's copies as one card (a search result, « Arts alternatifs » off): an added copy takes the player's default + * alt art for its copy (`addedCopyPrint`), a removed one leaves the prints chosen in the deck (`removedCopyPrint`). + */ + setFamilyQuantity(card: Card, choice: AltArtChoice, members: ReadonlySet, quantity: number): void { + if (!this.editable()) return; + const prints = familyPrints(this.lines(), members); + const target = Math.max(0, Math.min(this.maxFor(card), Math.round(quantity))); + let changed: { reference: string; delta: number } | null = null; + while (prints.length < target) { + const reference = addedCopyPrint(choice, prints); + prints.push(reference); + changed = { reference, delta: 1 }; + } + while (prints.length > target) { + const reference = removedCopyPrint(choice, prints); + prints.splice(prints.lastIndexOf(reference), 1); + changed = { reference, delta: -1 }; + } + if (!changed) return; + const after = prints.filter((p) => p === changed.reference).length; + this.lastChange.set({ card: printOf(card, changed.reference), quantity: after, delta: changed.delta }); + this.lines.set(withFamilyPrints(this.lines(), members, card, prints)); this.touch(); } /** - * « Global » alt-art mode: every card of a multi-art family takes the player's preferred prints, copy after copy - * (the site's deck builder does it on load and after each change); the hero takes the first slot. `false` when - * nothing changes. + * « Appliquer les arts par défaut »: the copies of every multi-art card of the deck, and the hero, take the player's + * default alt arts (`choices`: families by reference). `false` when nothing changes. */ - applyAltArtSlots(slotsByRef: ReadonlyMap): boolean { + applyAltArtDefaults(choices: Readonly>): boolean { if (!this.editable()) return false; - const lines = distributeSlots(this.lines(), slotsByRef); + const lines = linesWithDefaults(this.lines(), choices); const hero = this.hero(); - const heroRef = hero ? slotsByRef.get(hero.reference)?.slots[0] : undefined; + const heroChoice = hero ? choices[hero.reference] : undefined; + const heroRef = heroChoice ? defaultPrints(heroChoice, 1)[0] : undefined; const heroChanged = !!hero && !!heroRef && heroRef !== hero.reference; if (!lines && !heroChanged) return false; if (lines) this.lines.set(lines); @@ -377,7 +407,18 @@ export class DeckStore { /** A blank `name` keeps the current one. */ updateSettings(settings: { hero?: DeckHero; format?: DeckFormat; isPublic?: boolean; name?: string; description?: string }): void { if (!this.editable()) return; - if (settings.hero) this.hero.set(settings.hero); + const hero = settings.hero; + if (hero) { + this.hero.set(plainHero(hero)); + // Then its default alt art, unless another hero was picked meanwhile. + if (hero.defaultArt) { + this.heroWithDefaultArt(hero).subscribe((h) => { + if (h.reference === hero.reference || this.hero()?.reference !== hero.reference) return; + this.hero.set(h); + this.touch(); + }); + } + } if (settings.format) this.format.set(settings.format); if (settings.isPublic !== undefined) this.isPublic.set(settings.isPublic); if (settings.name?.trim()) this.name.set(settings.name.trim()); @@ -403,6 +444,16 @@ export class DeckStore { return this.guests.create({ name, description, format: this.format(), isPublic: false, hero: this.hero(), deckCards: this.serializeLines() }).id; } + /** `hero` with the player's default alt art for it (itself without one, or on an error). */ + private heroWithDefaultArt(hero: DeckHero): Observable { + return this.ownership.altArtChoices([hero.reference]).pipe( + map((choices) => { + const choice = choices[hero.reference]; + return { ...plainHero(hero), reference: choice ? defaultPrints(choice, 1)[0] : hero.reference }; + }), + ); + } + /** * Copies the open deck, private, and emits the copy's id: on the account when the user is signed * in (like the site's « Dupliquer »), in this browser otherwise. Fails with a displayable message. @@ -419,9 +470,10 @@ export class DeckStore { ...(description ? { description } : {}), deckCards: this.serializeLines().map((l) => ({ cardReference: l.cardReference, quantity: l.quantity })), }; - // « Global » alt-art preference: the copy takes the user's preferred illustrations (the site's duplicate does too). - return this.ownership.globalAltArts().pipe( - switchMap((global) => (global ? this.ownership.applyAltArts(body.deckCards ?? []) : of(body.deckCards ?? []))), + // Someone else's deck: the copy takes the user's default alt arts (their own deck keeps its prints). + const cards = body.deckCards ?? []; + const withDefaults = this.owned() === true ? of(cards) : this.ownership.altArtChoices(cards.map((c) => c.cardReference)).pipe(map((choices) => cardsWithDefaults(cards, choices))); + return withDefaults.pipe( switchMap((deckCards) => this.decksApi.create({ ...body, deckCards })), map((created) => { this.createdIds.update((ids) => new Set([...ids, created.id])); @@ -837,43 +889,25 @@ export function displayName(card: Card): string { return localizedText(card.name, contentLocale()) || card.reference; } -/** `from`'s copies become `to`'s (merged with an existing `to` line). */ -export function swapLines(lines: HydratedLine[], from: string, to: string): HydratedLine[] { - const moving = lines.find((l) => l.card.reference === from); - if (!moving) return lines; - const existing = lines.find((l) => l.card.reference === to); - const rest = lines.filter((l) => l.card.reference !== from); - if (existing) return rest.map((l) => (l === existing ? { ...l, quantity: l.quantity + moving.quantity } : l)); - return [...rest, { card: { ...moving.card, reference: to }, quantity: moving.quantity }]; +/** The hero as the deck stores it (without how it was picked). */ +function plainHero(hero: DeckHero): DeckHero { + return { reference: hero.reference, name: hero.name, faction: hero.faction }; +} + +/** `card` as another print of the same card (`reference`). */ +function printOf(card: Card, reference: string): Card { + return reference === card.reference ? card : { ...card, reference, imagePath: undefined }; } /** - * The copies of each family (all its lines together) spread over the player's slots: copy i takes slot i, the copies - * past the last slot repeat it (the site's `distributeAcrossSlots`). `null` when every line already matches. + * Deck lines to write (reference and quantity, with the rest of the line) with the player's default alt arts, families + * by reference in `choices`: an imported deck, or a copy of someone else's deck, takes the user's own illustrations. A + * new print copies its family's first line. */ -export function distributeSlots(lines: HydratedLine[], slotsByRef: ReadonlyMap): HydratedLine[] | null { - const families = new Map(); - for (const l of lines) { - const f = slotsByRef.get(l.card.reference); - if (!f || !f.slots.length) continue; - const g = families.get(f.key) ?? { slots: f.slots, qty: 0, lines: [] }; - g.qty += l.quantity; - g.lines.push(l); - families.set(f.key, g); - } - let changed = false; - let out = lines; - for (const g of families.values()) { - const counts = new Map(); - for (let i = 0; i < g.qty; i++) { - const ref = g.slots[Math.min(i, g.slots.length - 1)]; - counts.set(ref, (counts.get(ref) ?? 0) + 1); - } - const same = g.lines.length === counts.size && g.lines.every((l) => counts.get(l.card.reference) === l.quantity); - if (same) continue; - changed = true; - const template = g.lines[0].card; - out = out.filter((l) => !g.lines.includes(l)).concat([...counts].map(([reference, quantity]) => ({ card: { ...template, reference }, quantity }))); - } - return changed ? out : null; +export function cardsWithDefaults(cards: readonly T[], choices: Readonly>): T[] { + // Each line rides along in its card: `withFamilyPrints` copies a family's first card for its new prints. + const lines = cards.map((line) => ({ card: { reference: line.cardReference, line } as Card & { line: T }, quantity: line.quantity })); + const next = linesWithDefaults(lines, choices); + if (!next) return [...cards]; + return next.map((l) => ({ ...(l.card as Card & { line: T }).line, cardReference: l.card.reference, quantity: l.quantity })); } diff --git a/plugins/rebuilder/app/src/app/core/models.ts b/plugins/rebuilder/app/src/app/core/models.ts index 7b93098e..4884cdb6 100644 --- a/plugins/rebuilder/app/src/app/core/models.ts +++ b/plugins/rebuilder/app/src/app/core/models.ts @@ -194,6 +194,8 @@ export interface DeckHero { reference: string; name: string; faction: string; + /** Picked without « Alt arts »: the deck takes the player's default alt art for it (`DeckStore`). */ + defaultArt?: boolean; } export interface DeckWrite { diff --git a/plugins/rebuilder/app/src/app/core/ownership-api.service.spec.ts b/plugins/rebuilder/app/src/app/core/ownership-api.service.spec.ts index 22feb171..670851e0 100644 --- a/plugins/rebuilder/app/src/app/core/ownership-api.service.spec.ts +++ b/plugins/rebuilder/app/src/app/core/ownership-api.service.spec.ts @@ -3,26 +3,7 @@ import { HttpTestingController, provideHttpClientTesting } from '@angular/common import { TestBed } from '@angular/core/testing'; import { lastValueFrom } from 'rxjs'; import { environment } from '../../environments/environment'; -import { OwnershipApiService, mergeAltArtLines } from './ownership-api.service'; - -describe('mergeAltArtLines', () => { - it('flattens the lines and sums repeated references', () => { - expect( - mergeAltArtLines([ - [{ reference: 'ALT_A_ALT', quantity: 2 }, { reference: 'ALT_A', quantity: 1 }], - [{ reference: 'ALT_A', quantity: 2 }], - ]), - ).toEqual([ - { cardReference: 'ALT_A_ALT', quantity: 2 }, - { cardReference: 'ALT_A', quantity: 3 }, - ]); - }); - - it('is null without usable lines', () => { - expect(mergeAltArtLines(undefined)).toBeNull(); - expect(mergeAltArtLines([[{ quantity: 1 }]])).toBeNull(); - }); -}); +import { OwnershipApiService } from './ownership-api.service'; describe('OwnershipApiService', () => { const env = environment as { ownershipApiUrl: string }; @@ -40,25 +21,12 @@ describe('OwnershipApiService', () => { env.ownershipApiUrl = ''; }); - it('reads the « Global » mode, and treats an error as per-deck', async () => { + it('reads the « Global » alt-art mode, and treats an error as « par deck »', async () => { const global = lastValueFrom(api.globalAltArts()); http.expectOne('/api/v1/services/ownership/api/alt-arts/preference-mode').flush({ mode: 'Global' }); expect(await global).toBe(true); const failed = lastValueFrom(api.globalAltArts()); - http.expectOne('/api/v1/services/ownership/api/alt-arts/preference-mode').flush('down', { status: 502, statusText: 'Bad Gateway' }); + http.expectOne('/api/v1/services/ownership/api/alt-arts/preference-mode').flush('no', { status: 500, statusText: 'Error' }); expect(await failed).toBe(false); }); - - it('sends the cards as reference / quantity, and keeps them on an error', async () => { - const cards = [{ cardReference: 'ALT_A', quantity: 3 }]; - const applied = lastValueFrom(api.applyAltArts(cards)); - const req = http.expectOne('/api/v1/services/ownership/api/alt-arts/apply-to-deck'); - expect(req.request.method).toBe('POST'); - expect(req.request.body).toEqual([{ reference: 'ALT_A', quantity: 3 }]); - req.flush({ lines: [[{ reference: 'ALT_A_ALT', quantity: 3 }]] }); - expect(await applied).toEqual([{ cardReference: 'ALT_A_ALT', quantity: 3 }]); - const kept = lastValueFrom(api.applyAltArts(cards)); - http.expectOne('/api/v1/services/ownership/api/alt-arts/apply-to-deck').flush('no', { status: 500, statusText: 'Error' }); - expect(await kept).toBe(cards); - }); }); diff --git a/plugins/rebuilder/app/src/app/core/ownership-api.service.ts b/plugins/rebuilder/app/src/app/core/ownership-api.service.ts index 3c33c6bf..300a85e2 100644 --- a/plugins/rebuilder/app/src/app/core/ownership-api.service.ts +++ b/plugins/rebuilder/app/src/app/core/ownership-api.service.ts @@ -1,14 +1,9 @@ import { HttpClient, HttpParams } from '@angular/common/http'; -import { Service, inject, signal } from '@angular/core'; -import { Observable, catchError, map, of, tap } from 'rxjs'; +import { Service, inject } from '@angular/core'; +import { Observable, catchError, map, of } from 'rxjs'; import { environment } from '../../environments/environment'; import { contentLocale } from './locale'; -export interface CardQuantity { - cardReference: string; - quantity: number; -} - /** A multi-art family of the ownership catalog. */ export interface AltArtFamily { familyId: number; @@ -16,10 +11,13 @@ export interface AltArtFamily { rarity: string; } -/** The illustrations of a family (`ownedQuantity`: `null` = unlimited) and the player's copy slots (one per copy). */ +/** + * The illustrations of a family, the plain one first (`ownedQuantity`: `null` = unlimited), and the player's default + * alt arts: one slot a copy (1st, 2nd, 3rd), `isExplicitChoice` when the player chose it (else the plain one). + */ export interface AltArtOptions { options: { reference: string; ownedQuantity: number | null }[]; - slots: { slotIndex: number; reference: string }[]; + slots: { slotIndex: number; reference: string; isExplicitChoice?: boolean }[]; } export interface AltArtChoice { @@ -32,27 +30,15 @@ export function familyKey(f: AltArtFamily): string { } /** - * Digital ownership (`OWNERSHIP_API_URL`, through the site's relay `AlteredCore.services.ownership`): - * the user's alt-art preference. In « Global » mode, the cards of a new deck are swapped for the - * alt arts the user prefers and owns. Any error leaves the cards as they are: an import never fails - * because of this service. + * Digital ownership (`OWNERSHIP_API_URL`, through the site's relay `AlteredCore.services.ownership`): the families of + * illustrations, the copies the player owns and their default alt arts (`core/alt-art-defaults.ts`). Any error leaves + * the cards as they are: the deck never fails because of this service. */ @Service() export class OwnershipApiService { private readonly http = inject(HttpClient); readonly baseUrl = environment.ownershipApiUrl.replace(/\/$/, ''); - private readonly version = signal(0); - /** Bumped by each alt-art preference saved: what was read from the service before may be stale. */ - readonly altArtVersion = this.version.asReadonly(); - - /** `true` when the user applies their alt-art preferences to every deck. */ - globalAltArts(): Observable { - if (!this.baseUrl) return of(false); - return this.http.get<{ mode?: string }>(`${this.baseUrl}/api/alt-arts/preference-mode`).pipe( - map((body) => body?.mode === 'Global'), - catchError(() => of(false)), - ); - } + private readonly site = environment.siteUrl.replace(/\/$/, ''); /** * Families and illustrations of `references`, by reference (none for a card with a single illustration), from the @@ -61,7 +47,7 @@ export class OwnershipApiService { altArtChoices(references: string[]): Observable> { if (!this.baseUrl || !references.length) return of({}); const params = references.reduce((p, r) => p.append('ref[]', r), new HttpParams()); - const url = `${environment.siteUrl.replace(/\/$/, '')}/papi/core-altered-cards/deck-alt-arts`; + const url = `${this.site}/papi/core-altered-cards/deck-alt-arts`; return this.http.get<{ groups?: Record; options?: Record }>(url, { params }).pipe( map((res) => { const out: Record = {}; @@ -76,8 +62,8 @@ export class OwnershipApiService { } /** - * The token families with several illustrations (per-deck alt-art mode: tokens are not deck cards, their prints are a - * preference shared by every deck), from the site's `alt-art-search` (plugin ownership). + * The token families with several illustrations (tokens are not deck cards: their prints are a preference shared by + * every deck), from the site's `alt-art-search` (plugin ownership). */ tokenAltArts(): Observable<(AltArtChoice & { name: string })[]> { if (!this.baseUrl) return of([]); @@ -85,7 +71,7 @@ export class OwnershipApiService { .reduce((p, t) => p.append('type[]', t), new HttpParams()) .set('hideNonChoices', 'false') .set('locale', contentLocale()); - const url = `${environment.siteUrl.replace(/\/$/, '')}/papi/ownership/alt-art-search`; + const url = `${this.site}/papi/ownership/alt-art-search`; return this.http .get<{ families?: (AltArtFamily & { name?: string | Record })[]; options?: Record }>(url, { params }) .pipe( @@ -101,38 +87,18 @@ export class OwnershipApiService { /** Which illustration each copy slot of a family shows (`PUT /api/alt-arts/preferences`); errors reach the caller. */ setAltArtPreference(family: AltArtFamily, slotReferences: string[]): Observable { - return this.http - .put(`${this.baseUrl}/api/alt-arts/preferences`, { ...family, slotReferences }) - .pipe(tap({ complete: () => this.version.update((v) => v + 1) })); + return this.http.put(`${this.baseUrl}/api/alt-arts/preferences`, { ...family, slotReferences }); } - /** The cards with the preferred alt arts, as `POST /api/alt-arts/apply-to-deck` returns them. */ - applyAltArts(cards: CardQuantity[]): Observable { - if (!this.baseUrl || !cards.length) return of(cards); - const body = cards.map((c) => ({ reference: c.cardReference, quantity: c.quantity })); - return this.http.post<{ lines?: unknown }>(`${this.baseUrl}/api/alt-arts/apply-to-deck`, body).pipe( - map((res) => mergeAltArtLines(res?.lines) ?? cards), - catchError(() => of(cards)), + /** + * `true` in the « Global » alt-art mode: the ownership service rewrites every deck with the player's default alt + * arts, and the deck builders write them in the deck. `false` otherwise or on an error. + */ + globalAltArts(): Observable { + if (!this.baseUrl) return of(false); + return this.http.get<{ mode?: string }>(`${this.baseUrl}/api/alt-arts/preference-mode`).pipe( + map((body) => body?.mode === 'Global'), + catchError(() => of(false)), ); } } - -/** - * `lines[i]` is the list of references standing for input line `i` (an owned alt art for part of - * the copies, the printed card for the rest): flattened, with repeated references summed. `null` - * when the response has no usable lines. - */ -export function mergeAltArtLines(lines: unknown): CardQuantity[] | null { - if (!Array.isArray(lines)) return null; - const sum = new Map(); - for (const group of lines) { - for (const line of Array.isArray(group) ? group : [group]) { - const { reference, quantity } = (line ?? {}) as { reference?: unknown; quantity?: unknown }; - // Quantities read like PHP's `(int)` (the site's importer): numeric strings count. - const qty = Math.trunc(Number(quantity)); - if (typeof reference !== 'string' || !reference || !Number.isFinite(qty)) continue; - sum.set(reference, (sum.get(reference) ?? 0) + qty); - } - } - return sum.size ? [...sum].map(([cardReference, quantity]) => ({ cardReference, quantity })) : null; -} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html index d3f07a0b..d59b5d4a 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html @@ -12,15 +12,19 @@

{{ g.label }} · {{ g.count }}

- @for (l of g.lines; track l.card.reference) { + @for (l of g.lines; track trackOf(l)) { + @let stack = stacks().get(l.card.reference); }
diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts index dec394b5..fe209a96 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts @@ -1,12 +1,14 @@ -import { Component, DestroyRef, ElementRef, afterNextRender, computed, effect, inject, input, signal, untracked } from '@angular/core'; -import { DeckStore } from '../../../core/deck-store'; +import { Component, DestroyRef, ElementRef, afterNextRender, computed, inject, input, signal } from '@angular/core'; +import { DeckStore, displayName } from '../../../core/deck-store'; import type { Card, HydratedLine } from '../../../core/models'; +import { familyKey } from '../../../core/ownership-api.service'; import { AcCardPile } from '../../../ui/metier'; import { AcSkeleton } from '../../../ui/containers'; import { AcOverlayService } from '../../../ui/overlay'; import { EditorAltArts } from '../../editor/editor-alt-arts'; import { lineIssues } from '../../editor/editor-legality'; import { openCardZoom } from '../../shared/card-zoom/card-zoom.overlay'; +import { openAltArtPicker } from '../../editor/alt-art-picker/alt-art-picker.overlay'; import { boardGroups, fitColumns, layoutBoard } from './board-layout'; /** Narrowest card the board shows, to keep it readable: a wider board gets more columns, so fewer rows. */ @@ -19,8 +21,17 @@ const DEFAULT_COLUMNS = 8; /** * « Aperçu » from 768 px: the whole deck at a glance, as its image. One column group per type, each card a pile of its - * copies. Editable (editor): a stepper on each pile; a card brought to 0 keeps its place, faded, until the view is left. + * copies. Editable (editor): a stepper on each pile; a card brought to 0 leaves the board (« Annuler » in the toast). + * « Regrouper les illustrations » (editor): the prints of a card share one pile, copy 1 of its brush in front, and the + * pile's stepper adds and removes copies of the card as the search does. */ + +/** A pile of a card's prints: its copies (copy 1 first), its family, its legality issues. */ +interface Stack { + key: string; + prints: string[]; + issues: readonly string[]; +} @Component({ selector: 'app-deck-board', imports: [AcCardPile, AcSkeleton], @@ -31,32 +42,50 @@ export class DeckBoard { protected readonly deck = inject(DeckStore); private readonly overlay = inject(AcOverlayService); /** Editor only: prints of the deck's cards (per-deck alt-art mode). */ - private readonly altArts = inject(EditorAltArts, { optional: true }); + protected readonly altArts = inject(EditorAltArts, { optional: true }); readonly readonly = input(false); /** The board's width, measured: the number of card columns follows it. */ private readonly width = signal(0); /** Steppers on the piles (the editor's board): DeckStore.steppersShown. */ protected readonly editable = computed(() => !this.readonly() && this.deck.steppersShown()); - /** Cards brought to 0 with a pile's stepper since this view opened, by reference: they keep their place, faded. */ - private readonly removed = signal(new Map()); - /** The groups, sorted: recomputed when the cards change, not when the board is resized. */ - private readonly board = computed(() => { - const lines: HydratedLine[] = this.deck.lines().filter((l) => l.quantity > 0); - const present = new Set(lines.map((l) => l.card.reference)); - if (this.editable()) { - for (const [ref, card] of this.removed()) if (!present.has(ref)) lines.push({ card, quantity: 0 }); + protected readonly issues = computed(() => lineIssues(this.deck)); + protected readonly noIssues: readonly string[] = []; + /** The board's lines, a card's prints on one pile when « Regrouper les illustrations » is on, and those piles. */ + private readonly piles = computed(() => { + const lines = this.deck.lines().filter((l) => l.quantity > 0); + const stacks = new Map(); + const alt = this.altArts; + if (!alt || !this.editable() || !alt.stackPrints()) return { lines, stacks }; + const issues = this.issues(); + const out: HydratedLine[] = []; + const done = new Set(); + for (const line of lines) { + const choice = alt.choiceFor(line.card.reference); + const members = choice ? alt.members(choice) : null; + if (!choice || !members || members.size < 2) { + out.push(line); + continue; + } + const key = familyKey(choice.family); + if (done.has(key)) continue; + done.add(key); + const prints = alt.copyPrints(line.card) ?? []; + const front = lines.find((l) => l.card.reference === prints[0])?.card ?? line.card; + out.push({ card: front, quantity: prints.length }); + stacks.set(front.reference, { key, prints, issues: [...new Set([...members].flatMap((r) => issues.get(r) ?? []))] }); } - return boardGroups(lines); + return { lines: out, stacks }; }); + protected readonly stacks = computed(() => this.piles().stacks); + /** The groups, sorted: recomputed when the cards change, not when the board is resized. */ + private readonly board = computed(() => boardGroups(this.piles().lines)); /** Card columns the width allows: a number, so a resize that keeps it changes nothing downstream. */ private readonly columns = computed(() => { const width = this.width(); return width ? fitColumns(width, this.board().length, MIN_CARD, PADDING, GAP) : DEFAULT_COLUMNS; }); protected readonly groups = computed(() => layoutBoard(this.board(), this.columns())); - protected readonly issues = computed(() => lineIssues(this.deck)); - protected readonly noIssues: readonly string[] = []; constructor() { const host = inject>(ElementRef).nativeElement; @@ -67,40 +96,46 @@ export class DeckBoard { observer.observe(host); destroyRef.onDestroy(() => observer.disconnect()); }); - // Another deck loaded: its own cards only. - effect(() => { - this.deck.deckId(); - untracked(() => this.removed.set(new Map())); - }); } - /** - * A pile's stepper. Only a card removed here stays on the board at 0: a reference that leaves the deck otherwise (an - * illustration swapped in the zoom, the preferred prints of Global mode) leaves no faded pile behind. - */ + /** A pile keeps its place while its front print changes. */ + protected trackOf(line: HydratedLine): string { + return this.stacks().get(line.card.reference)?.key ?? line.card.reference; + } + + /** A pile's copies: of the card's whole family on a pile of its prints. */ protected setQuantity(card: Card, quantity: number): void { - if (!this.deck.editable()) return; - this.deck.setQuantity(card, quantity); - this.removed.update((map) => { - const next = new Map(map); - if (quantity === 0) next.set(card.reference, card); - else next.delete(card.reference); - return next; - }); + if (this.altArts && this.stacks().has(card.reference)) this.altArts.setFamilyQuantity(card, quantity); + else this.deck.setQuantity(card, quantity); } protected quantityOf(card: Card): number { - return this.deck.quantities().get(card.reference) ?? 0; + return this.stacks().get(card.reference)?.prints.length ?? this.deck.quantities().get(card.reference) ?? 0; + } + + /** Copies a pile can have: the card's limit on a pile of its prints, shared with its other prints otherwise. */ + protected maxOf(card: Card): number { + return this.stacks().has(card.reference) ? this.deck.maxFor(card) : (this.altArts?.maxFor(card, false) ?? this.deck.maxFor(card)); + } + + /** `card` has a brush (several illustrations, the deck editable). */ + protected canIllustrate(card: Card): boolean { + return this.editable() && !!this.altArts?.canIllustrate(card); + } + + /** The brush of a card: its illustrations in this deck. */ + protected illustrate(card: Card): void { + const data = this.altArts?.pickerFor(card); + if (data) openAltArtPicker(this.overlay, displayName(card), data); } /** The card large, with its copies and prints when the deck can be edited (the site's card lightbox). */ protected zoom(card: Card): void { const editable = this.editable(); - const choice = this.altArts?.choiceFor(card.reference) ?? null; openCardZoom(this.overlay, { card, - quantity: editable ? { value: this.quantityOf(card), max: this.deck.maxFor(card), change: (n) => this.setQuantity(card, n) } : undefined, - illustrations: editable && choice ? { choice, pick: (ref) => this.deck.swapReference(card, ref) } : undefined, + quantity: editable ? { value: this.quantityOf(card), max: this.maxOf(card), change: (n) => this.setQuantity(card, n) } : undefined, + illustrations: editable ? (this.altArts?.pickerFor(card) ?? undefined) : undefined, }); } } diff --git a/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.spec.ts b/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.spec.ts index 206fec88..b2662425 100644 --- a/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.spec.ts +++ b/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.spec.ts @@ -5,7 +5,7 @@ import { of, throwError, type Observable } from 'rxjs'; import { AuthSession } from '../../../core/auth-session'; import { DecksApiService } from '../../../core/decks-api.service'; import type { Deck, DeckWrite } from '../../../core/models'; -import { OwnershipApiService, type CardQuantity } from '../../../core/ownership-api.service'; +import { OwnershipApiService, type AltArtChoice } from '../../../core/ownership-api.service'; import { EquinoxImport } from './equinox-import'; /** A ZIP with one stored entry. */ @@ -62,8 +62,8 @@ describe('EquinoxImport', () => { let created: DeckWrite[]; let failNext: number; let mine: Deck[]; - let global: boolean; - let altArts: (cards: CardQuantity[]) => CardQuantity[]; + /** The player's families, by reference (their default alt arts). */ + let choices: Record; let mineError: number; function setup(): Harness { @@ -89,8 +89,7 @@ describe('EquinoxImport', () => { { provide: OwnershipApiService, useValue: { - globalAltArts: () => of(global), - applyAltArts: (cards: CardQuantity[]) => of(altArts(cards)), + altArtChoices: (refs: string[]) => of(Object.fromEntries(refs.filter((r) => choices[r]).map((r) => [r, choices[r]]))), }, }, ], @@ -104,9 +103,8 @@ describe('EquinoxImport', () => { vi.useFakeTimers(); failNext = 0; mine = []; - global = false; mineError = 0; - altArts = (cards) => cards.map((c) => ({ ...c, cardReference: `${c.cardReference}_ALT` })); + choices = {}; }); afterEach(() => vi.useRealTimers()); @@ -128,16 +126,22 @@ describe('EquinoxImport', () => { expect(cmp.phase()).toBe('done'); }); - it('applies the « Global » alt-art preference to the exported cards, not to the hero', async () => { - global = true; + it('gives the cards and the hero the player’s default alt arts, within the copies owned', async () => { + choices = { + ALT_A: family(1, ['ALT_A', 'ALT_A_ALT'], 2, ['ALT_A_ALT', 'ALT_A_ALT', 'ALT_A_ALT']), + ALT_HERO: family(2, ['ALT_HERO', 'ALT_HERO_P'], 1, ['ALT_HERO_P']), + }; const cmp = setup(); await run(cmp); - expect(created[0].deckCards).toEqual([{ cardReference: 'ALT_HERO', quantity: 1 }, { cardReference: 'ALT_A_ALT', quantity: 3 }]); + expect(created[0].deckCards).toEqual([ + { cardReference: 'ALT_HERO_P', quantity: 1 }, + { cardReference: 'ALT_A_ALT', quantity: 2 }, + { cardReference: 'ALT_A', quantity: 1 }, + ]); }); - it('keeps the exported cards when the alt-art answer holds an invalid card', async () => { - global = true; - altArts = (cards) => cards.map((c) => ({ ...c, quantity: 0 })); + it('keeps the exported cards when the defaults give an invalid card', async () => { + choices = { ALT_A: family(1, ['ALT_A', 'oops'], null, ['oops']) }; const cmp = setup(); await run(cmp); expect(created[0].deckCards).toEqual([{ cardReference: 'ALT_HERO', quantity: 1 }, { cardReference: 'ALT_A', quantity: 3 }]); @@ -215,3 +219,11 @@ describe('EquinoxImport', () => { expect(cmp.rows().filter((r) => r.status === 'cancelled').length).toBe(2 - importedWhilePaused); }); }); + +/** A family of `prints` (the plain one first, unlimited; the others owned `owned` times) with the default `slots`. */ +function family(id: number, prints: string[], owned: number | null, slots: string[]): AltArtChoice { + return { + family: { familyId: id, faction: 'AX', rarity: 'C' }, + options: { options: prints.map((reference, i) => ({ reference, ownedQuantity: i === 0 ? null : owned })), slots: slots.map((reference, i) => ({ slotIndex: i + 1, reference })) }, + }; +} diff --git a/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.ts b/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.ts index 26cea41d..8ee18e96 100644 --- a/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.ts +++ b/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.ts @@ -13,6 +13,7 @@ import { uiLocale } from '../../../core/i18n'; import { AcBreakpointService } from '../../../ui/layout.services'; import { type DeckCardRef, type EquinoxDeck, parseEquinoxCsv, sameDeck, validCards, withHero } from './equinox-csv'; import { OwnershipApiService } from '../../../core/ownership-api.service'; +import { cardsWithDefaults } from '../../../core/deck-store'; export type ImportStatus = 'pending' | 'current' | 'imported' | 'skipped' | 'failed' | 'failedFinal' | 'cancelled'; @@ -304,15 +305,12 @@ export class EquinoxImport { // A cancel, or a new file, while the request was in flight: its answer is dropped. const stale = () => this.cancelled() || run !== this.run; try { - // « Global » alt-art preference (read for each deck, as the site's importer does): the user's - // alt arts replace the exported cards, unless the answer holds an invalid card; the hero is - // added afterwards, as it was exported. - let exported = row.deck.cards; - if (await lastValueFrom(this.ownership.globalAltArts())) { - const applied = await lastValueFrom(this.ownership.applyAltArts(exported)); - if (validCards(applied)) exported = applied; - } - const deck = { name: row.deck.name, cards: withHero(row.deck.hero, exported) }; + // The cards and the hero take the user's default alt arts (`cardsWithDefaults`), unless that gives an invalid card. + let cards = withHero(row.deck.hero, row.deck.cards); + const choices = await lastValueFrom(this.ownership.altArtChoices(cards.map((c) => c.cardReference))); + const applied = cardsWithDefaults(cards, choices); + if (validCards(applied)) cards = applied; + const deck = { name: row.deck.name, cards }; let status: ImportStatus = 'skipped'; if (!(await this.alreadyThere(deck))) { await lastValueFrom( diff --git a/plugins/rebuilder/app/src/app/features/editor/alt-art-labels.ts b/plugins/rebuilder/app/src/app/features/editor/alt-art-labels.ts new file mode 100644 index 00000000..d4d33d66 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/alt-art-labels.ts @@ -0,0 +1,3 @@ +/** Labels shared by the illustration windows (brush, tokens). */ +export const printLabel = (n: number) => $localize`:@@altArt.tile:Illustration ${n}:n:`; +export const saveFailed = () => $localize`:@@altArt.saveError:Impossible d’enregistrer votre choix.`; diff --git a/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.html b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.html new file mode 100644 index 00000000..3d3ace50 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.html @@ -0,0 +1,61 @@ +
+

Ces choix ne valent que pour ce deck : vos arts par défaut ne changent pas.

+
+
+

+ + Choisissez l’illustration de chaque carte +

+

Cliquez sur une case au-dessus d’une illustration pour la lui attribuer.

+
    + @for (a of arts(); track a.reference) { +
  • +
    + @for (k of a.keys; track k.rank) { + + } +
    + + {{ a.label }} + {{ a.note }} +
  • + } +
+ @if (error(); as message) { + + } +
+ +
+
+ + diff --git a/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.scss b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.scss new file mode 100644 index 00000000..8ee5174b --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.scss @@ -0,0 +1,198 @@ +/* + * Two steps on one level: the illustrations on a band ending in a chevron, which fits into the notch of « Ce que le + * deck utilise »; on a phone, one under the other, the chevron pointing down. + */ +.ac-overlay-body { + display: flex; + flex-direction: column; + gap: var(--ac-space-4); +} +.stage { + --chevron: 44px; + display: flex; + align-items: stretch; +} +p, +h3, +ul { + margin: 0; +} +ul { + padding: 0; + list-style: none; +} +.pool, +.deck { + position: relative; + isolation: isolate; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: var(--ac-space-3); +} +.pool::before, +.deck::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; +} +.pool { + flex: 1; + min-width: 0; + padding: var(--ac-space-4) calc(var(--chevron) + var(--ac-space-6)) var(--ac-space-4) var(--ac-space-5); +} +.pool::before { + border-radius: var(--ac-radius-card) 0 0 var(--ac-radius-card); + background: var(--ac-color-primary-tint); + clip-path: polygon(0 0, calc(100% - var(--chevron)) 0, 100% 50%, calc(100% - var(--chevron)) 100%, 0 100%); +} +.step { + display: flex; + align-items: center; + gap: var(--ac-space-2); + font: var(--ac-font-body-strong); + color: var(--ac-color-text); +} +.step-num { + flex: none; + display: inline-grid; + place-items: center; + width: var(--ac-space-6); + aspect-ratio: 1; + border-radius: var(--ac-radius-pill); + background: var(--ac-color-primary); + color: var(--ac-color-on-primary); + font: var(--ac-font-caption); +} +.deck .step-num { + background: var(--ac-color-inverse); + color: var(--ac-color-on-inverse); +} +.defaults-note { + font: var(--ac-font-small); + color: var(--ac-color-text-muted); +} +.prints { + flex: 1; + display: flex; + flex-wrap: wrap; + justify-content: center; + align-content: center; + gap: var(--ac-space-6) var(--ac-space-10); +} +/* An illustration under its three keys. */ +.print { + width: min(200px, calc(50% - var(--ac-space-3))); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ac-space-0-5); + text-align: center; +} +.art { + display: block; + width: 100%; + margin-bottom: var(--ac-space-2); + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: var(--ac-radius-lg); +} +.unowned .art { + opacity: 0.4; +} +.print-label { + font: var(--ac-font-body-strong); + color: var(--ac-color-text); +} +.print-note { + font: var(--ac-font-caption); + color: var(--ac-color-text-muted); +} +/* The cards' keys (ac-keys, design system): the 1st card on the left, the 2nd in the middle, the 3rd on the right. */ +.keys { + align-self: stretch; + margin-bottom: var(--ac-space-2); +} +.keys-hint { + margin-top: calc(-1 * var(--ac-space-2)); + padding-left: calc(var(--ac-space-6) + var(--ac-space-2)); + font: var(--ac-font-small); + color: var(--ac-color-text-muted); +} +.error { + font: var(--ac-font-small); + color: var(--ac-color-danger); +} +/* What the deck uses: its copies fanned out, copy 1 on the left. */ +.deck { + flex: none; + width: 300px; + margin-left: calc(var(--ac-space-4) - var(--chevron)); + padding: var(--ac-space-4) var(--ac-space-5) var(--ac-space-4) calc(var(--chevron) + var(--ac-space-5)); +} +.deck::before { + border-radius: 0 var(--ac-radius-card) var(--ac-radius-card) 0; + background: var(--ac-color-bg-app); + clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%, var(--chevron) 50%); +} +.result { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + gap: var(--ac-space-4); +} +.fan { + --card: 92px; + --step: 62px; + position: relative; + height: calc(var(--card) * 7 / 5 + var(--ac-space-6)); +} +.fan img { + position: absolute; + top: var(--ac-space-3); + left: calc(50% - (var(--card) + (var(--n) - 1) * var(--step)) / 2 + var(--i) * var(--step)); + width: var(--card); + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: var(--ac-radius-sm); + box-shadow: var(--ac-shadow-card); + transform: rotate(calc((var(--i) - (var(--n) - 1) / 2) * 6deg)); + transform-origin: 50% 120%; +} +.summary { + font: var(--ac-font-body-strong); + color: var(--ac-color-text); +} +.settings { + margin-right: auto; + align-self: center; + font-size: var(--ac-font-size-small); + color: var(--ac-color-link); +} +@media (max-width: 767px) { + .stage { + --chevron: 28px; + flex-direction: column; + } + .pool { + padding: var(--ac-space-4) var(--ac-space-4) calc(var(--chevron) + var(--ac-space-4)); + } + .pool::before { + border-radius: var(--ac-radius-card) var(--ac-radius-card) 0 0; + clip-path: polygon(0 0, 100% 0, 100% calc(100% - var(--chevron)), 50% 100%, 0 calc(100% - var(--chevron))); + } + .deck { + width: auto; + margin: calc(var(--ac-space-3) - var(--chevron)) 0 0; + padding: calc(var(--chevron) + var(--ac-space-4)) var(--ac-space-5) var(--ac-space-5); + } + .deck::before { + border-radius: 0 0 var(--ac-radius-card) var(--ac-radius-card); + clip-path: polygon(0 0, 50% var(--chevron), 100% 0, 100% 100%, 0 100%); + } + .prints { + gap: var(--ac-space-5) var(--ac-space-4); + } +} diff --git a/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.ts b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.ts new file mode 100644 index 00000000..3ada993c --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.ts @@ -0,0 +1,146 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import type { Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; +import { canRank, defaultPrints, rankPrints, sameCopies } from '../../../core/alt-art-defaults'; +import { cardImageUrl } from '../../../core/card-art'; +import type { AltArtChoice } from '../../../core/ownership-api.service'; +import { AcButton } from '../../../ui/buttons'; +import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; +import { printLabel, saveFailed } from '../alt-art-labels'; + +export interface AltArtPickerData { + /** The card's family, with the player's default alt arts. */ + choice: () => AltArtChoice; + /** The family's copies in the deck, one print a copy. */ + prints: () => readonly string[]; + /** The illustration of the 1st, 2nd and 3rd card in this deck. */ + cards: () => readonly string[]; + /** The family's copies in the deck, one print a copy, copy 1 first (as on the deck board). */ + copies: () => readonly string[]; + /** The 1st, 2nd and 3rd card take `cards` in this deck, its copies too; errors reach the caller. */ + setCards: (cards: readonly string[]) => Observable; + /** The family follows the default alt arts again in this deck. */ + reset: () => void; +} + +/** + * The brush of a card: its illustrations, each under three keys, its 1st, 2nd and 3rd card (one a copy). A key pressed + * is a card that takes this illustration in the deck; pressing another key gives the illustration that card. Then what + * the deck uses (with 2 copies, the 1st and 2nd cards; an added copy takes the 3rd card's). A change here is for this + * deck only: its copies take it at once, the player's default alt arts stay as they are. + */ +@Component({ + selector: 'app-alt-art-picker', + imports: [AcButton], + host: { class: 'ac-overlay-content' }, + templateUrl: './alt-art-picker.overlay.html', + styleUrl: './alt-art-picker.overlay.scss', +}) +export class AltArtPickerOverlay { + protected readonly ref = inject>(AcOverlayRef); + private readonly data = this.ref.data!; + private readonly choice = this.data.choice; + /** The illustration of each card in this deck, 1st first. */ + private readonly ranks = this.data.cards; + private readonly copies = computed(() => this.data.prints().length); + /** The deck's cards and copies are the default alt arts. */ + protected readonly followsDefaults = computed( + () => this.ranks().join() === rankPrints(this.choice()).join() && sameCopies(this.data.prints(), defaultPrints(this.choice(), this.copies())), + ); + protected readonly saving = signal(false); + protected readonly error = signal(null); + protected readonly cardWord = cardWord(); + /** The site's « Arts alternatifs par défaut » page (plugin ownership). */ + protected readonly settingsUrl = `${environment.siteUrl.replace(/\/$/, '')}/pages/ownership-alt-arts`; + + /** The illustrations in the service's order (the plain print first): a choice does not move them. */ + protected readonly arts = computed(() => { + const choice = this.choice(); + const ranks = this.ranks(); + return choice.options.options.map((o, i) => { + const label = printLabel(i + 1); + return { + reference: o.reference, + src: cardImageUrl(o.reference), + label, + note: o.ownedQuantity === null ? unlimited() : o.ownedQuantity === 0 ? notOwned() : ownedLabel(o.ownedQuantity), + unowned: o.ownedQuantity === 0, + keysLabel: keysLabel(label), + /** The 1st card on the left, the 2nd in the middle, the 3rd on the right. */ + keys: ranks.map((r, k) => ({ + rank: k, + num: k + 1, + suffix: cardSuffix(k), + label: cardLabel(k), + pressed: r === o.reference, + disabled: r !== o.reference && !canRank(choice, ranks, k, o.reference), + })), + }; + }); + }); + + /** The deck's copies, copy 1 first (as on the deck board). */ + protected readonly preview = computed(() => this.data.copies().map((print) => cardImageUrl(print))); + + protected readonly summary = computed(() => { + const n = this.copies(); + if (n === 0) return summaryNone(); + return n === 1 ? summaryOne() : n === 2 ? summaryTwo() : summaryAll(n); + }); + + /** Card `rank` takes illustration `reference` in this deck, its copies too. */ + protected choose(reference: string, rank: number): void { + const ranks = this.ranks(); + if (ranks[rank] === reference || this.saving()) return; + this.error.set(null); + this.saving.set(true); + this.data.setCards(ranks.map((r, k) => (k === rank ? reference : r))).subscribe({ + complete: () => this.saving.set(false), + error: () => { + this.saving.set(false); + this.error.set(saveFailed()); + }, + }); + } + + /** « Arts par défaut »: the card follows the default alt arts again in this deck. */ + protected resetToDefaults(): void { + this.error.set(null); + this.data.reset(); + } +} + +const cardLabel = (k: number) => + k === 0 + ? $localize`:@@editor.altArtPicker.card1:1ère carte` + : k === 1 + ? $localize`:@@editor.altArtPicker.card2:2ème carte` + : $localize`:@@editor.altArtPicker.card3:3ème carte`; +/** Ordinal suffix on a key (« 1ère », « 2ème »). */ +const cardSuffix = (k: number) => + k === 0 + ? $localize`:@@editor.altArtPicker.suffix1:ère` + : k === 1 + ? $localize`:@@editor.altArtPicker.suffix2:ème` + : $localize`:@@editor.altArtPicker.suffix3:ème`; +const cardWord = () => $localize`:@@editor.altArtPicker.cardWord:carte`; +const keysLabel = (label: string) => $localize`:@@editor.altArtPicker.menu:Cartes de ${label}:label:`; +const unlimited = () => $localize`:@@editor.altArtPicker.unlimited:Illimitée`; +const notOwned = () => $localize`:@@editor.altArtPicker.notOwned:Non possédée`; +const ownedLabel = (n: number) => + n > 1 ? $localize`:@@editor.altArtPicker.ownedMany:${n}:n: possédées` : $localize`:@@editor.altArtPicker.ownedOne:1 possédée`; +const summaryNone = () => $localize`:@@editor.altArtPicker.summaryNone:Ce deck n’a pas encore d’exemplaire de cette carte.`; +const summaryOne = () => $localize`:@@editor.altArtPicker.summaryOne:Avec 1 exemplaire, le deck prend l’illustration de la 1ère carte.`; +const summaryTwo = () => $localize`:@@editor.altArtPicker.summaryTwo:Avec 2 exemplaires, le deck prend les illustrations des 1ère et 2ème cartes.`; +const summaryAll = (n: number) => $localize`:@@editor.altArtPicker.summaryAll:Avec ${n}:n: exemplaires, le deck prend les illustrations des trois cartes.`; + +/** The brush of a card of the deck, in its own window (sheet on a phone). */ +export function openAltArtPicker(overlay: AcOverlayService, name: string, data: AltArtPickerData): AcOverlayRef { + const copies = data.prints().length; + return overlay.open(AltArtPickerOverlay, { + title: name, + subtitle: copies > 1 ? $localize`:@@editor.altArtPicker.copiesMany:${copies}:n: exemplaires dans ce deck` : $localize`:@@editor.altArtPicker.copiesOne:1 exemplaire dans ce deck`, + data, + width: 1000, + }); +} diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html index 088dc205..e0a7c7aa 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html +++ b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html @@ -5,7 +5,12 @@ } @if (!readonly() && altArts && altArts.tokensChoice() !== false) { - +
+ + @if (altArts.defaultsChoice() !== false) { + + } +
}
@@ -205,7 +218,7 @@ } } @case ('deck') { - + } @case ('main') { @if (deck.opening()) { diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts index a597f7f5..12e18aaa 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts @@ -10,7 +10,7 @@ import { GuestDeckService } from '../../../core/guest-deck.service'; import { formatInfo } from '../../../core/formats'; import { rarityLimits } from '../../../core/deck-rules'; import { AcButton, AcIconButton, MENU_POSITIONS } from '../../../ui/buttons'; -import { AcSegmented } from '../../../ui/fields'; +import { AcSegmented, AcSwitch } from '../../../ui/fields'; import { AcIcon } from '../../../ui/icon'; import { AcDrawerHandle, AcDrawerState, AcDrawerTab, AcSkeleton, AcToast } from '../../../ui/containers'; import { storedFlag } from '../../../core/stored-flag'; @@ -23,7 +23,7 @@ import { AcOverlayService } from '../../../ui/overlay'; import { openDeckSettings } from '../../shared/deck-settings/deck-settings.overlay'; import { openHeroPicker } from '../../shared/hero-picker/hero-picker.overlay'; import { openDeckOverview } from '../deck-overview/deck-overview.overlay'; -import { EditorAltArts } from '../editor-alt-arts'; +import { EditorAltArts, confirmAltArtDefaults } from '../editor-alt-arts'; import { CardSearchStore } from '../../search/card-search.store'; import { CardSearch } from '../../search/card-search/card-search'; import { DeckListView } from '../deck-list-view/deck-list-view'; @@ -54,6 +54,7 @@ export type EditorAction = 'share'; selector: 'app-editor-page', providers: [CardSearchStore, EditorAltArts], imports: [ + AcSwitch, AcToast, AcDrawerHandle, AcDrawerTab, @@ -342,6 +343,12 @@ export class EditorPage { openTokenArts(this.overlay); } + protected applyAltArtDefaults(): void { + confirmAltArtDefaults(this.overlay, this.altArts).subscribe((message) => { + if (message) this.flash(message); + }); + } + /** The deck's image: copied, saved or opened (reduced bar: the button and its « ⋯ » menu). */ protected async image(action: DeckImageAction): Promise { const source = this.imageSource(); diff --git a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.html b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.html index e3e93964..0bca8488 100644 --- a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.html +++ b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.html @@ -1,12 +1,25 @@
-

Les préférences d’illustration des jetons sont communes à tous les decks : elles ne font pas partie de la liste de cartes de ce deck.

- @if (families(); as list) { - @for (f of list; track f.family.familyId + f.family.faction + f.family.rarity) { -
- @if (f.name) { -

{{ f.name }}

- } - +

Choisissez une illustration par jeton : elle s’applique à tous ses exemplaires, dans tous vos decks.

+ @if (tokens(); as list) { + @for (t of list; track t.key) { +
+ +
+

{{ t.name }}

+
+ @for (p of t.prints; track p.reference) { + + } +
+ {{ t.count }} + @if (error(); as e) { + @if (e.key === t.key) { + + } + } +
} @empty {

Aucun jeton n’a plus d’une illustration.

diff --git a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.scss b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.scss index 0bc3894d..eb00387e 100644 --- a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.scss +++ b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.scss @@ -8,8 +8,90 @@ font-size: var(--ac-font-size-small); color: var(--ac-color-text-muted); } +/* One token: its illustration large, then the illustrations to choose from. */ +.token { + display: flex; + gap: var(--ac-space-4); + padding: var(--ac-space-3); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-card); + background: var(--ac-color-surface); +} +.preview { + flex-shrink: 0; + align-self: center; + width: 200px; + height: auto; + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: var(--ac-radius-control); + box-shadow: var(--ac-shadow-card); +} +.side { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--ac-space-2); +} h3 { - margin: 0 0 var(--ac-space-2); + margin: 0; font-size: var(--ac-font-size-body); - font-weight: 700; + font-weight: 800; +} +.prints { + display: flex; + flex-wrap: wrap; + gap: var(--ac-space-2); +} +.print { + width: 84px; + padding: 0; + border: 3px solid transparent; + border-radius: var(--ac-radius-md); + background: var(--ac-color-track); + cursor: pointer; + transition: transform 0.12s; +} +.print img { + display: block; + width: 100%; + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: var(--ac-radius-sm); +} +.print.on { + border-color: var(--ac-color-primary); + transform: translateY(-4px); +} +.print:disabled { + cursor: not-allowed; +} +.print:disabled img { + opacity: 0.35; +} +.print:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: 2px; +} +.error { + margin: 0; + font-size: var(--ac-font-size-caption); + color: var(--ac-color-danger); +} +@media (max-width: 767px) { + .token { + gap: var(--ac-space-3); + } + .preview { + width: 110px; + } + .print { + width: 64px; + } +} +@media (prefers-reduced-motion: reduce) { + .print { + transition: none; + } } diff --git a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.ts b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.ts index 47f76285..f0b729f3 100644 --- a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.ts +++ b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.ts @@ -1,32 +1,87 @@ -import { Component, inject } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, computed, inject, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { catchError, of } from 'rxjs'; -import { OwnershipApiService } from '../../../core/ownership-api.service'; +import { slotPrints } from '../../../core/alt-art-defaults'; +import { cardImageUrl } from '../../../core/card-art'; +import { OwnershipApiService, familyKey, type AltArtChoice } from '../../../core/ownership-api.service'; import { AcButton } from '../../../ui/buttons'; import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; -import { AltArtSlots } from '../../shared/alt-art-slots/alt-art-slots'; +import { printLabel, saveFailed } from '../alt-art-labels'; -/** « Illustrations des jetons »: the prints of the tokens, a preference shared by every deck (the site's window). */ +/** + * « Illustrations des jetons »: one illustration a token, shared by every deck (a token is not a deck card), saved as + * the player's preference on the ownership service at once. A large preview of the chosen one, then the others. + */ @Component({ selector: 'app-token-arts', - imports: [AltArtSlots, AcButton], + imports: [AcButton], host: { class: 'ac-overlay-content' }, templateUrl: './token-arts.overlay.html', styleUrl: './token-arts.overlay.scss', }) export class TokenArtsOverlay { protected readonly ref = inject(AcOverlayRef); + private readonly ownership = inject(OwnershipApiService); /** `undefined` while loading, `[]` when no token has several illustrations (or on an error). */ - protected readonly families = toSignal( - inject(OwnershipApiService) - .tokenAltArts() - .pipe(catchError(() => of([]))), - ); + private readonly families = toSignal(this.ownership.tokenAltArts().pipe(catchError(() => of([])))); + /** Illustrations chosen here, by family key (before the service confirms). */ + private readonly chosen = signal>>({}); + /** A token whose choice was refused, with the reason. */ + protected readonly error = signal<{ key: string; message: string } | null>(null); + private readonly saving = new Set(); + + protected readonly tokens = computed(() => { + const list = this.families(); + if (!list) return undefined; + const chosen = this.chosen(); + return list.map((f, i) => { + const key = familyKey(f.family); + const current = chosen[key] ?? slotPrints(f)[0] ?? f.options.options[0]?.reference ?? ''; + return { + key, + family: f, + name: f.name || tokenLabel(i + 1), + current, + src: cardImageUrl(current), + count: availableLabel(f.options.options.length), + prints: f.options.options.map((o, j) => ({ + reference: o.reference, + src: cardImageUrl(o.reference), + on: o.reference === current, + unowned: o.ownedQuantity === 0, + label: o.ownedQuantity === 0 ? notOwnedLabel(j + 1) : printLabel(j + 1), + })), + }; + }); + }); + + protected pick(token: { key: string; family: AltArtChoice; current: string }, reference: string): void { + if (reference === token.current || this.saving.has(token.key)) return; + const before = this.chosen(); + this.chosen.set({ ...before, [token.key]: reference }); + this.error.set(null); + this.saving.add(token.key); + this.ownership.setAltArtPreference(token.family.family, [reference]).subscribe({ + next: () => this.saving.delete(token.key), + complete: () => this.saving.delete(token.key), + error: (err: unknown) => { + this.saving.delete(token.key); + this.chosen.set(before); + this.error.set({ key: token.key, message: err instanceof HttpErrorResponse && err.status === 409 ? notEnoughCopies() : saveFailed() }); + }, + }); + } } +const tokenLabel = (n: number) => $localize`:@@editor.tokenArts.token:Jeton ${n}:n:`; +const notOwnedLabel = (n: number) => $localize`:@@editor.tokenArts.notOwned:Illustration ${n}:n: (non possédée)`; +const availableLabel = (n: number) => $localize`:@@editor.tokenArts.available:${n}:n: illustrations disponibles`; +const notEnoughCopies = () => $localize`:@@altArt.notEnoughCopies:Vous n’avez pas assez d’exemplaires de cet art alternatif.`; + export function openTokenArts(overlay: AcOverlayService): AcOverlayRef { return overlay.open(TokenArtsOverlay, { title: $localize`:@@editor.tokenArts.title:Illustrations des jetons`, - width: 640, + width: 760, }); } diff --git a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html index ef847918..85c1ca66 100644 --- a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html +++ b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html @@ -23,11 +23,11 @@ [card]="c" [eager]="grid.first() + $index < 6" [plain]="browse()" - [quantity]="deck.quantities().get(c.reference) ?? 0" - [max]="deck.maxFor(c)" + [quantity]="quantityOf(c)" + [max]="maxOf(c)" [blockedReason]="blockedReason(c)" [zoomable]="true" - (quantityChange)="deck.setQuantity(c, $event)" + (quantityChange)="setQuantity(c, $event)" (zoom)="zoom(c)" [favorite]="favorites.enabled() ? favorites.has(c.reference) : null" (favoriteToggle)="favorites.toggle(c)" @@ -47,10 +47,10 @@ appearance="panel" [plain]="browse()" [card]="c" - [quantity]="deck.quantities().get(c.reference) ?? 0" - [max]="deck.maxFor(c)" + [quantity]="quantityOf(c)" + [max]="maxOf(c)" [blockedReason]="blockedReason(c)" - (quantityChange)="deck.setQuantity(c, $event)" + (quantityChange)="setQuantity(c, $event)" /> }
diff --git a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.ts b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.ts index 2c6eab1e..92f137e4 100644 --- a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.ts +++ b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject, input } from '@angular/core'; +import { Component, computed, effect, inject, input, untracked } from '@angular/core'; import { RouterLink } from '@angular/router'; import { isAccountSource } from '../../../core/card-filters'; import { addBlockedReason } from '../../../core/deck-rules'; @@ -13,6 +13,7 @@ import { AcOverlayService } from '../../../ui/overlay'; import { FavoritesService } from '../../../core/favorites.service'; import { openCardZoom } from '../../shared/card-zoom/card-zoom.overlay'; import { CardSearchStore } from '../card-search.store'; +import { EditorAltArts } from '../../editor/editor-alt-arts'; @Component({ selector: 'app-search-results', @@ -26,12 +27,37 @@ export class SearchResults { protected readonly auth = inject(AuthSession); private readonly overlay = inject(AcOverlayService); protected readonly favorites = inject(FavoritesService); + /** Editor: a card's plain print stands for all its prints, a copy added takes its default alt art. */ + private readonly altArts = inject(EditorAltArts, { optional: true }); readonly layout = input<'grid' | 'list'>('grid'); /** Card browser: cards without quantity controls. */ readonly browse = input(false); protected readonly accountTab = computed(() => isAccountSource(this.search.source())); protected readonly skeletons = computed(() => Array.from({ length: this.search.cards().length ? 3 : 6 })); + constructor() { + // The families of the results: the copies a card counts and the print an added copy takes. + effect(() => { + const cards = this.search.cards(); + if (!this.browse() && this.altArts?.enabled()) untracked(() => this.altArts?.request(cards.map((c) => c.reference))); + }); + } + + /** Copies in the deck: the whole family for a plain print, that print otherwise. */ + protected quantityOf(card: Card): number { + if (this.altArts?.isFamilyCard(card)) return this.altArts.familyQuantity(card); + return this.deck.quantities().get(card.reference) ?? 0; + } + + protected maxOf(card: Card): number { + return this.altArts ? this.altArts.maxFor(card, true) : this.deck.maxFor(card); + } + + protected setQuantity(card: Card, quantity: number): void { + if (this.altArts) this.altArts.setQuantity(card, quantity); + else this.deck.setQuantity(card, quantity); + } + /** Why a card cannot be added in the deck's format (a Unique in a No Unique format). */ protected blockedReason(card: Card): string | null { return addBlockedReason(card, this.deck.format()); @@ -42,7 +68,7 @@ export class SearchResults { const editable = !this.browse() && this.deck.editable() && !this.blockedReason(card); openCardZoom(this.overlay, { card, - quantity: editable ? { value: this.deck.quantities().get(card.reference) ?? 0, max: this.deck.maxFor(card), change: (n) => this.deck.setQuantity(card, n) } : undefined, + quantity: editable ? { value: this.quantityOf(card), max: this.maxOf(card), change: (n) => this.setQuantity(card, n) } : undefined, }); } diff --git a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.html b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.html deleted file mode 100644 index 37eaa8ac..00000000 --- a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.html +++ /dev/null @@ -1,23 +0,0 @@ -
    - @for (t of tiles(); track t.reference) { -
  • - - @if (t.markers.length) { - - @if (single()) { - - } @else { - @for (m of t.markers; track m.slotIndex) { - - } - } - - } -
  • - } -
-@if (error(); as e) { - -} diff --git a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.scss b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.scss deleted file mode 100644 index 43e0d629..00000000 --- a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.scss +++ /dev/null @@ -1,74 +0,0 @@ -:host { - display: block; -} -.strip { - margin: 0; - padding: 0 0 var(--ac-space-1); - list-style: none; - display: flex; - gap: var(--ac-space-2); - overflow-x: auto; -} -li { - position: relative; - flex-shrink: 0; -} -.tile { - display: block; - width: 64px; - padding: 0; - border: 0; - border-radius: var(--ac-radius-md); - overflow: hidden; - background: var(--ac-color-track); - cursor: pointer; -} -.tile img { - display: block; - width: 100%; - aspect-ratio: 5 / 7; - object-fit: cover; -} -.tile.unowned { - opacity: 0.35; - cursor: not-allowed; -} -.tile:focus-visible, -.marker:focus-visible { - outline: 2px solid var(--ac-color-primary); - outline-offset: 2px; -} -.markers { - position: absolute; - left: 50%; - bottom: var(--ac-space-1); - transform: translateX(-50%); - display: flex; - gap: 2px; -} -.marker { - width: 18px; - height: 18px; - padding: 0; - border: 1px solid var(--ac-color-on-strong); - border-radius: var(--ac-radius-pill); - background: var(--ac-color-overlay-control); - color: var(--ac-color-on-strong); - font-size: 10px; - font-weight: 700; -} -button.marker { - cursor: pointer; -} -span.marker { - display: grid; - place-items: center; -} -.marker.active { - background: var(--ac-color-primary); -} -.error { - margin: var(--ac-space-2) 0 0; - font-size: var(--ac-font-size-caption); - color: var(--ac-color-danger); -} diff --git a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.spec.ts b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.spec.ts deleted file mode 100644 index 4f62b055..00000000 --- a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { TestBed } from '@angular/core/testing'; -import { of, throwError } from 'rxjs'; -import { OwnershipApiService, type AltArtChoice } from '../../../core/ownership-api.service'; -import { AltArtSlots } from './alt-art-slots'; - -const CHOICE: AltArtChoice = { - family: { familyId: 7, faction: 'AX', rarity: 'COMMON' }, - options: { - options: [ - { reference: 'ALT_CORE_B_AX_04_C', ownedQuantity: null }, - { reference: 'ALT_CORE_A_AX_04_C', ownedQuantity: 2 }, - { reference: 'ALT_CORE_P_AX_04_C', ownedQuantity: 0 }, - ], - slots: [0, 1, 2].map((slotIndex) => ({ slotIndex, reference: 'ALT_CORE_B_AX_04_C' })), - }, -}; - -describe('AltArtSlots', () => { - function setup(result: 'ok' | 409, choice: AltArtChoice = CHOICE) { - const saved: string[][] = []; - TestBed.configureTestingModule({ - providers: [ - { - provide: OwnershipApiService, - useValue: { - setAltArtPreference: (_f: unknown, refs: string[]) => { - saved.push(refs); - return result === 'ok' ? of(undefined) : throwError(() => new HttpErrorResponse({ status: 409, error: [{ reference: 'ALT_CORE_A_AX_04_C', requested: 3, owned: 2 }] })); - }, - }, - }, - ], - }); - const fixture = TestBed.createComponent(AltArtSlots); - fixture.componentRef.setInput('choice', choice); - fixture.detectChanges(); - const el = fixture.nativeElement as HTMLElement; - return { fixture, el, saved }; - } - - it('moves the copies onto an owned illustration in order, from the first, and saves the slots', () => { - const { fixture, el, saved } = setup('ok'); - el.querySelectorAll('.tile')[1].click(); - fixture.detectChanges(); - expect(saved).toEqual([['ALT_CORE_A_AX_04_C', 'ALT_CORE_B_AX_04_C', 'ALT_CORE_B_AX_04_C']]); - expect([...el.querySelectorAll('li')[1].querySelectorAll('.marker')].map((m) => m.textContent?.trim())).toEqual(['1']); - expect(el.querySelectorAll('.tile')[2].disabled).toBe(true); - }); - - it('puts the copy back with the reason when the site refuses', () => { - const { fixture, el } = setup(409); - el.querySelectorAll('.tile')[1].click(); - fixture.detectChanges(); - expect(el.querySelectorAll('li')[1].querySelectorAll('.marker')).toHaveLength(0); - expect(el.querySelector('.error')?.textContent?.trim()).toBe('Vous n’avez pas assez d’exemplaires de cet art alternatif.'); - }); - - it('says the player lacks copies, without saving, when they pick an illustration once more than they own', () => { - const { fixture, el, saved } = setup('ok'); - const altArt = el.querySelectorAll('.tile')[1]; - altArt.click(); - fixture.detectChanges(); - altArt.click(); - fixture.detectChanges(); - expect(saved).toHaveLength(2); - altArt.click(); - fixture.detectChanges(); - expect(saved).toHaveLength(2); - expect([...el.querySelectorAll('li')[1].querySelectorAll('.marker')].map((m) => m.textContent?.trim())).toEqual(['1', '2']); - expect(el.querySelector('.error')?.textContent?.trim()).toBe('Vous n’avez pas assez d’exemplaires de cet art alternatif.'); - }); - - it('numbers the copies 1, 2, 3 whatever the API\'s slot indexes', () => { - const fromOne = { ...CHOICE, options: { ...CHOICE.options, slots: [1, 2, 3].map((slotIndex) => ({ slotIndex, reference: 'ALT_CORE_B_AX_04_C' })) } }; - const { el } = setup('ok', fromOne); - const markers = [...el.querySelectorAll('li:first-child .marker')]; - expect(markers.map((m) => m.textContent?.trim())).toEqual(['1', '2', '3']); - expect(markers.map((m) => m.getAttribute('aria-label'))).toEqual(['Exemplaire 1', 'Exemplaire 2', 'Exemplaire 3']); - }); - - it('shows one marker without a number for a family with one copy (token, hero)', () => { - const token = { ...CHOICE, options: { ...CHOICE.options, slots: [{ slotIndex: 1, reference: 'ALT_CORE_A_AX_04_C' }] } }; - const { fixture, el, saved } = setup('ok', token); - const marker = el.querySelector('li:nth-child(2) .marker'); - expect(marker?.textContent?.trim()).toBe(''); - expect(marker?.getAttribute('aria-label')).toBe('Illustration choisie'); - expect(el.querySelectorAll('.marker')).toHaveLength(1); - el.querySelectorAll('.tile')[0].click(); - fixture.detectChanges(); - expect(saved).toEqual([['ALT_CORE_B_AX_04_C']]); - expect(el.querySelector('li:nth-child(1) .marker')).not.toBeNull(); - }); -}); diff --git a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.ts b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.ts deleted file mode 100644 index 6f5c5749..00000000 --- a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Component, computed, inject, input, linkedSignal, signal } from '@angular/core'; -import { HttpErrorResponse } from '@angular/common/http'; -import { finalize } from 'rxjs'; -import { cardImageUrl } from '../../../core/card-art'; -import { OwnershipApiService, type AltArtChoice } from '../../../core/ownership-api.service'; -import { AcIcon } from '../../../ui/icon'; - -/** - * The illustrations of a card family with the player's copies on them (one marker a copy), as the site's alt-art - * widget in the card lightbox: an owned illustration takes the active marker, the active marker then moves on; the - * site saves the choice and a refusal puts the marker back, with the reason. Markers are numbered by copy (1, 2, 3), - * not by the API's `slotIndex`; a family with one copy (hero, token) has a single marker without a number. - */ -@Component({ - selector: 'app-alt-art-slots', - imports: [AcIcon], - templateUrl: './alt-art-slots.html', - styleUrl: './alt-art-slots.scss', -}) -export class AltArtSlots { - private readonly ownership = inject(OwnershipApiService); - readonly choice = input.required(); - protected readonly slots = linkedSignal(() => [...this.choice().options.slots].sort((a, b) => a.slotIndex - b.slotIndex)); - /** The first copy moves first, then the next ones (the site's widget), so successive picks fill copies 1, 2, 3. */ - protected readonly active = linkedSignal(() => this.slots()[0]?.slotIndex ?? 0); - protected readonly single = computed(() => this.slots().length === 1); - protected readonly error = signal(null); - /** A save is running: the tiles wait for it, so that saves never cross (a late refusal would undo a later choice). */ - protected readonly saving = signal(false); - protected readonly tiles = computed(() => { - const copies = this.slots().map((s, i) => ({ ...s, copy: i + 1 })); - return this.choice().options.options.map((o) => { - const markers = copies.filter((s) => s.reference === o.reference); - return { - reference: o.reference, - src: cardImageUrl(o.reference), - owned: o.ownedQuantity === null || o.ownedQuantity > 0, - /** Every owned copy of this illustration is already on a copy of the deck. */ - full: o.ownedQuantity !== null && markers.length >= o.ownedQuantity, - markers, - }; - }); - }); - protected readonly tileLabel = (n: number) => $localize`:@@altArt.tile:Illustration ${n}:n:`; - protected readonly markerLabel = (n: number) => $localize`:@@altArt.marker:Exemplaire ${n}:n:`; - protected readonly chosenLabel = $localize`:@@altArt.chosen:Illustration choisie`; - - /** An owned illustration: the active marker (or the next one not already there) moves onto it. */ - protected pick({ reference, owned, full }: { reference: string; owned: boolean; full: boolean }): void { - if (!owned || this.saving()) return; - const slots = this.slots(); - const start = Math.max(0, slots.findIndex((s) => s.slotIndex === this.active())); - let moved: { slotIndex: number; reference: string } | undefined; - for (let i = 0; i < slots.length; i++) { - const candidate = slots[(start + i) % slots.length]; - if (candidate.reference !== reference) { - moved = candidate; - break; - } - } - if (!moved) return; - if (full) { - this.error.set(notEnoughCopies()); - return; - } - const before = slots; - const beforeActive = this.active(); - const next = slots.map((s) => (s.slotIndex === moved.slotIndex ? { ...s, reference } : s)); - const index = next.findIndex((s) => s.slotIndex === moved.slotIndex); - this.slots.set(next); - this.active.set(next[(index + 1) % next.length].slotIndex); - this.error.set(null); - this.saving.set(true); - this.ownership.setAltArtPreference(this.choice().family, next.map((s) => s.reference)).pipe(finalize(() => this.saving.set(false))).subscribe({ - error: (err: unknown) => { - this.slots.set(before); - this.active.set(beforeActive); - this.error.set(saveError(err)); - }, - }); - } - - protected selectMarker(slotIndex: number, event: Event): void { - event.stopPropagation(); - this.active.set(slotIndex); - } -} - -const notEnoughCopies = () => $localize`:@@altArt.notEnoughCopies:Vous n’avez pas assez d’exemplaires de cet art alternatif.`; - -/** A refusal for copies the player does not own (409) says so; any other error, that the choice was not saved. */ -function saveError(err: unknown): string { - return err instanceof HttpErrorResponse && err.status === 409 ? notEnoughCopies() : $localize`:@@altArt.saveError:Impossible d’enregistrer votre choix.`; -} diff --git a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.html b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.html index 8addf639..24572340 100644 --- a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.html +++ b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.html @@ -1,33 +1,18 @@
-
- +
+
- - @if (altArts() || data.quantity || (data.illustrations && prints().length > 1)) { + + @if (data.quantity || data.illustrations) {
- @if (altArts(); as choice) { - - } @if (data.quantity; as q) {
Dans le deck
} - @if (data.illustrations && prints().length > 1) { - - @if (choosing()) { -
    - @for (p of prints(); track p.reference) { -
  • - -
  • - } -
- - } + @if (data.illustrations) { + }
} diff --git a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.scss b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.scss index e64cd66d..1dd7bcb9 100644 --- a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.scss +++ b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.scss @@ -23,14 +23,11 @@ /* Never smaller than 40 % of the screen: below that, the window scrolls. */ min-height: min(40dvh, 100cqw * 7 / 5); container-type: size; - perspective: 900px; } .card { display: block; width: min(100cqw, 100cqh * 5 / 7); margin-inline: auto; - transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); - transition: transform 0.12s ease-out; } .qty { display: flex; @@ -40,37 +37,3 @@ font-size: var(--ac-font-size-small); font-weight: 700; } -.prints { - margin: 0; - padding: 0; - list-style: none; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); - gap: var(--ac-space-2); -} -.print { - display: block; - width: 100%; - padding: 0; - border: 2px solid transparent; - border-radius: var(--ac-radius-md); - overflow: hidden; - background: var(--ac-color-track); - cursor: pointer; -} -.print img { - display: block; - width: 100%; - aspect-ratio: 5 / 7; - object-fit: cover; -} -.print.selected { - border-color: var(--ac-color-primary); -} -.print.unowned { - opacity: 0.5; -} -.print:focus-visible { - outline: 2px solid var(--ac-color-primary); - outline-offset: 2px; -} diff --git a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.ts b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.ts index a807304b..d7fc749e 100644 --- a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.ts +++ b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.ts @@ -1,80 +1,51 @@ import { Component, computed, inject, signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { map, of, switchMap } from 'rxjs'; import { environment } from '../../../../environments/environment'; import { contentLocale } from '../../../core/locale'; -import { cardImageUrl } from '../../../core/card-art'; import type { Card } from '../../../core/models'; import { localizedText } from '../../../core/models'; import { AcButton, AcStepper } from '../../../ui/buttons'; import { AcCardTile } from '../../../ui/metier'; -import { OwnershipApiService, type AltArtChoice } from '../../../core/ownership-api.service'; -import { AltArtSlots } from '../alt-art-slots/alt-art-slots'; import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; +import { openAltArtPicker, type AltArtPickerData } from '../../editor/alt-art-picker/alt-art-picker.overlay'; export interface CardZoomData { card: Card; /** Editor: copies in the deck, changed from the window (as the site's deck builder lightbox). */ quantity?: { value: number; max: number; change: (quantity: number) => void }; - /** Editor, per-deck alt-art mode: « Choisir une illustration » for a card of the deck (the site's lightbox). */ - illustrations?: { choice: AltArtChoice; pick: (reference: string) => void }; + /** Editor, a card with several illustrations: « Choisir les illustrations » opens its brush in place of this window. */ + illustrations?: AltArtPickerData; } /** A card shown large, with a link to its sheet on the site (the site's card lightbox); in the editor, its copies. */ @Component({ selector: 'app-card-zoom', - imports: [AcCardTile, AcStepper, AcButton, AltArtSlots], + imports: [AcCardTile, AcStepper, AcButton], host: { class: 'ac-overlay-content' }, templateUrl: './card-zoom.overlay.html', styleUrl: './card-zoom.overlay.scss', }) export class CardZoomOverlay { private readonly ref = inject>(AcOverlayRef); - private readonly ownership = inject(OwnershipApiService); + private readonly overlay = inject(AcOverlayService); protected readonly data = this.ref.data; - /** - * « Global » alt-art preference (as on the site): the card tilts under the pointer and its illustrations show with the - * player's copies, `null` otherwise or for a card with one illustration. - */ - private readonly global = toSignal(this.ownership.globalAltArts(), { initialValue: false }); - protected readonly altArts = toSignal( - this.ownership.globalAltArts().pipe( - switchMap((global) => (global ? this.ownership.altArtChoices([this.data.card.reference]) : of>({}))), - map((choices) => choices[this.data.card.reference] ?? null), - ), - { initialValue: null }, - ); - protected readonly tilt = signal({ x: 0, y: 0 }); - protected readonly choosing = signal(false); - protected readonly chosen = signal(this.data.card.reference); - protected readonly prints = computed(() => - (this.data.illustrations?.choice.options.options ?? []).map((o) => ({ reference: o.reference, src: cardImageUrl(o.reference), unowned: o.ownedQuantity === 0 })), - ); - protected readonly printLabel = (n: number) => $localize`:@@altArt.tile:Illustration ${n}:n:`; - - /** Unowned prints stay selectable: Board Game Arena shows the base art for the missing copies (as on the site). */ - protected confirmPrint(): void { - const ref = this.chosen(); - if (ref !== this.data.card.reference) this.data.illustrations?.pick(ref); - this.ref.close(); - } protected readonly quantity = signal(this.data.quantity?.value ?? 0); protected readonly name = computed(() => localizedText(this.data.card.name, contentLocale()) || this.data.card.reference); /** The site's card sheet (`/pages/card`, plugin core-altered-cards). */ protected readonly detailUrl = `${environment.siteUrl.replace(/\/$/, '')}/pages/card?ref=${encodeURIComponent(this.data.card.reference)}&card_lang=${contentLocale()}`; - protected onTilt(event: PointerEvent, el: HTMLElement): void { - if (!this.global() || event.pointerType === 'touch') return; - const r = el.getBoundingClientRect(); - const x = (event.clientX - r.left) / r.width - 0.5; - const y = (event.clientY - r.top) / r.height - 0.5; - this.tilt.set({ x: Math.round(-y * 16), y: Math.round(x * 16) }); - } - protected setQuantity(n: number): void { this.quantity.set(n); this.data.quantity?.change(n); } + + /** The brush is a window of its own (the zoom has no frame for it): the zoom closes first, never two windows. */ + protected chooseIllustrations(): void { + const data = this.data.illustrations; + if (!data) return; + const name = this.name(); + this.ref.close(); + openAltArtPicker(this.overlay, name, data); + } } export function openCardZoom(overlay: AcOverlayService, data: CardZoomData): AcOverlayRef { diff --git a/plugins/rebuilder/app/src/app/features/shared/hero-picker/hero-picker.overlay.html b/plugins/rebuilder/app/src/app/features/shared/hero-picker/hero-picker.overlay.html index 695817d7..1977cdc9 100644 --- a/plugins/rebuilder/app/src/app/features/shared/hero-picker/hero-picker.overlay.html +++ b/plugins/rebuilder/app/src/app/features/shared/hero-picker/hero-picker.overlay.html @@ -3,14 +3,12 @@

Le héros détermine votre faction et les cartes disponibles.

} - @if (!global()) { -
- - @if (altArts()) { - - } -
- } +
+ + @if (altArts()) { + + } +
>(AcOverlayRef); protected readonly bp = inject(AcBreakpointService); private readonly load = injectHeroes(); protected readonly altArts = signal(false); protected readonly serialized = signal(false); protected readonly heroes = computed(() => heroChoices(this.load.heroes(), { altArts: this.altArts(), serialized: this.serialized() })); - /** « Alt arts » is hidden in « Global » alt-art mode: the preferred print comes from the player's preferences. */ - protected readonly global = toSignal(this.ownership.globalAltArts(), { initialValue: false }); protected readonly error = this.load.error; protected readonly picked = signal(this.ref.data?.selected ?? null); protected readonly faction = signal(this.ref.data?.selected?.faction || 'AX'); @@ -107,7 +103,8 @@ export class HeroPickerOverlay { confirm(): void { const p = this.picked(); - if (p) this.ref.close({ reference: p.reference, name: p.name, faction: p.faction }); + // Without « Alt arts », the hero takes the player's default alt art. + if (p) this.ref.close({ reference: p.reference, name: p.name, faction: p.faction, ...(this.altArts() ? {} : { defaultArt: true }) }); } } diff --git a/plugins/rebuilder/app/src/app/features/shared/new-deck.form.ts b/plugins/rebuilder/app/src/app/features/shared/new-deck.form.ts index c89cfd32..ddc5b815 100644 --- a/plugins/rebuilder/app/src/app/features/shared/new-deck.form.ts +++ b/plugins/rebuilder/app/src/app/features/shared/new-deck.form.ts @@ -24,9 +24,10 @@ export class NewDeckForm { readonly ready = computed(() => !!this.hero() && !!this.name().trim()); private generatedName = ''; - selectHero(hero: DeckHero | null): void { + /** `defaultArt`: picked without « Alt arts », the hero takes the player's default alt art. */ + selectHero(hero: DeckHero | null, defaultArt = false): void { if (!hero) return; - this.hero.set({ reference: hero.reference, name: hero.name, faction: hero.faction }); + this.hero.set({ reference: hero.reference, name: hero.name, faction: hero.faction, ...(defaultArt ? { defaultArt } : {}) }); const current = this.name(); if (!current.trim() || current === this.generatedName) { this.generatedName = $localize`:@@newDeck.defaultName:Deck ${hero.name}:hero:`; diff --git a/plugins/rebuilder/app/src/app/features/shared/new-deck.spec.ts b/plugins/rebuilder/app/src/app/features/shared/new-deck.spec.ts index e17e3ce3..4e897b07 100644 --- a/plugins/rebuilder/app/src/app/features/shared/new-deck.spec.ts +++ b/plugins/rebuilder/app/src/app/features/shared/new-deck.spec.ts @@ -131,7 +131,8 @@ describe('NewDeckOverlay', () => { input.value = 'Mon deck'; input.dispatchEvent(new Event('input')); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); - expect(closed).toEqual([{ name: 'Mon deck', hero: TREYST, format: 'standard', isPublic: false, description: '' }]); + // Picked without « Alt arts »: the deck takes the player's default alt art for the hero. + expect(closed).toEqual([{ name: 'Mon deck', hero: { ...TREYST, defaultArt: true }, format: 'standard', isPublic: false, description: '' }]); }); }); diff --git a/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.html b/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.html index edeb49a7..8f217a5e 100644 --- a/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.html +++ b/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.html @@ -13,14 +13,12 @@ controls="new-deck-heroes" [(active)]="faction" /> - @if (!global()) { -
- - @if (altArts()) { - - } -
- } +
+ + @if (altArts()) { + + } +
diff --git a/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.ts b/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.ts index 88704b33..86fc2401 100644 --- a/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.ts +++ b/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.ts @@ -7,8 +7,6 @@ import { AcBreakpointService } from '../../../ui/layout.services'; import { AcFactionTabs, AcHeroSelector, factionName } from '../../../ui/metier'; import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; import { heroChoices, injectHeroes } from '../hero-picker/hero-picker.overlay'; -import { OwnershipApiService } from '../../../core/ownership-api.service'; -import { toSignal } from '@angular/core/rxjs-interop'; import { NewDeckForm, type NewDeckResult } from '../new-deck.form'; export type { NewDeckResult } from '../new-deck.form'; @@ -34,7 +32,6 @@ export const VISIBILITY_OPTIONS = [ styleUrl: './new-deck.overlay.scss', }) export class NewDeckOverlay { - private readonly ownership = inject(OwnershipApiService); private readonly destroyRef = inject(DestroyRef); protected readonly ref = inject>(AcOverlayRef); protected readonly bp = inject(AcBreakpointService); @@ -50,7 +47,6 @@ export class NewDeckOverlay { protected readonly altArts = signal(false); protected readonly serialized = signal(false); protected readonly heroes = computed(() => heroChoices(this.load.heroes(), { altArts: this.altArts(), serialized: this.serialized() })); - protected readonly global = toSignal(this.ownership.globalAltArts(), { initialValue: false }); protected readonly error = this.load.error; readonly form = new NewDeckForm(); diff --git a/plugins/rebuilder/app/src/app/ui/icon/icon.ts b/plugins/rebuilder/app/src/app/ui/icon/icon.ts index 84cd90e6..d2b5d64b 100644 --- a/plugins/rebuilder/app/src/app/ui/icon/icon.ts +++ b/plugins/rebuilder/app/src/app/ui/icon/icon.ts @@ -26,6 +26,9 @@ export const AC_ICONS = { pencil: '', 'sliders-horizontal': '', moon: '', + brush: '', + 'wand-sparkles': '', + images: '', layers: '', 'layout-grid': '', menu: '', diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.html b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.html index 41dfdd63..6ffbc41e 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.html +++ b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.html @@ -1,19 +1,24 @@ - + @if (unique()) { } @else { - + } @if (issues().length && quantity() > 0) { } +@if (illustrations()) { + +} @if (quantity() > 1) { } diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.scss b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.scss index 2a566532..4e18b0e1 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.scss +++ b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.scss @@ -76,6 +76,37 @@ font: var(--ac-font-caption); pointer-events: none; } +/* Brush: top right of the front card, always shown (the card has several illustrations). */ +.illustrate { + position: absolute; + top: calc(var(--pile-behind, 0) * var(--pile-offset) + var(--ac-space-1-5)); + right: var(--ac-space-1-5); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: var(--ac-radius-md); + background: var(--ac-color-surface); + color: var(--ac-color-primary); + box-shadow: var(--ac-shadow-fab); + cursor: pointer; +} +.illustrate::after { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: max(100%, var(--ac-hit-min)); + height: max(100%, var(--ac-hit-min)); + transform: translate(-50%, -50%); +} +.illustrate:focus-visible { + outline: 2px solid var(--ac-color-primary); + outline-offset: 2px; +} /* The stepper: over the card while it is hovered or holds the focus (always shown on touch screens). */ .stepper { position: absolute; diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.ts b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.ts index 42098a3b..4237c72b 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.ts +++ b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.ts @@ -11,7 +11,7 @@ import { AcUniqueCard } from '../unique-card/unique-card'; /** * A card of the deck board (« Aperçu »): its copies stacked, each one a few pixels lower, « ×n » from 2 copies. Editable: - * the « − n + » stepper over the card on hover or focus; at 0 the pile stays, faded, until the view is left. A line + * the « − n + » stepper over the card on hover or focus; faded at 0. A line * with legality issues gets an orange ring and marker. */ @Component({ @@ -22,6 +22,7 @@ import { AcUniqueCard } from '../unique-card/unique-card'; '[class.zero]': 'quantity() === 0', '[class.issue]': 'issues().length > 0 && quantity() > 0', '[class.editable]': '!readonly()', + '[style.--pile-behind]': 'behind().length', }, templateUrl: './card-pile.html', styleUrl: './card-pile.scss', @@ -29,6 +30,8 @@ import { AcUniqueCard } from '../unique-card/unique-card'; export class AcCardPile { readonly card = input.required(); readonly quantity = input(1); + /** The copies' prints, front first (a pile of a card's illustrations); `null`: every copy shows `card`. */ + readonly prints = input(null); readonly max = input(3); readonly readonly = input(true); /** Why the line is not legal (the deck panel's tooltips). */ @@ -36,11 +39,16 @@ export class AcCardPile { readonly quantityChange = output(); /** The card was clicked: the page opens it large. */ readonly zoom = output(); + /** Brush over the front card (a card with several illustrations, in an editable deck): emits `illustrate`. */ + readonly illustrations = input(false); + readonly illustrate = output(); protected readonly name = computed(() => localizedText(this.card().name, contentLocale()) || this.card().reference); protected readonly unique = computed(() => isUniqueReference(this.card().reference)); /** Copies behind the front one (a pile at 0 shows one card). */ protected readonly behind = computed(() => Array.from({ length: Math.max(0, Math.min(this.quantity(), 3) - 1) }, (_, i) => i)); + /** The print of each copy behind the front one. */ + protected readonly layers = computed(() => this.behind().map((i) => ({ i, reference: this.prints()?.[i + 1] ?? this.card().reference }))); protected readonly zoomLabel = computed(() => { const n = this.quantity(); return n === 1 @@ -49,4 +57,5 @@ export class AcCardPile { }); protected readonly copiesLabel = computed(() => $localize`:@@ui.cardTile.copiesInDeck:Exemplaires de ${this.name()}:name: dans le deck`); protected readonly issueLabel = computed(() => this.issues().join(' · ')); + protected readonly illustrateLabel = computed(() => $localize`:@@ui.cardTile.illustrate:Choisir les illustrations de ${this.name()}:name:`); } diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-tile/card-tile.html b/plugins/rebuilder/app/src/app/ui/metier/card-tile/card-tile.html index f6bd00af..9557a2d3 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/card-tile/card-tile.html +++ b/plugins/rebuilder/app/src/app/ui/metier/card-tile/card-tile.html @@ -14,6 +14,11 @@
} +@if (illustrations()) { + +} @if (favorite() !== null) {