From a29823d5dc89e6f7342c2a615e8c1395766ebd5f Mon Sep 17 00:00:00 2001 From: Yutsa Date: Thu, 8 Oct 2026 14:09:16 +0200 Subject: [PATCH 01/38] Default alt arts: one setting for the defaults, final prints per deck in Re:Builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ownership service's « Global » mode rewrote every deck with the global preferences, on the site and on Board Game Arena (apply-to-deck), so a deck could not keep prints of its own. The settings page now sets default alt arts only, and a deck stores the prints it uses: - Settings (plugin ownership): one mode, « Arts alternatifs par défaut », for cards and tokens. - A player still in « Global » is switched to « par deck » on their next visit (includes/func.alt-arts.php); their decks wait for their default alt arts until Re:Builder opens them (table alt_art_pending_decks). - Re:Builder: a card added from the search takes the default alt art of its copy; « Arts alternatifs » in the search lists the alt arts the player owns after their card, added as shown; the brush of a card changes its prints for the deck; « Arts par défaut » applies the defaults to the deck; the token window shows each token large with its illustrations. Import, a copy of someone else's deck and a hero picked without « Alt arts » take the defaults. - Local stack: docker-compose.ownership.yml runs the real AlteredOwnership service, with owned alt arts, default alt arts and a deck to switch from « Global ». Co-Authored-By: Claude Opus 5.5 --- README.md | 8 + docker-compose.ownership.yml | 108 ++++++++ docker/stack/config.stack.php | 5 +- docker/stack/ownership-mock/index.php | 5 +- docker/stack/ownership-real/config.php | 5 + .../stack/ownership-real/keycloak-scopes.sh | 19 ++ docker/stack/ownership-real/seed-decks.php | 79 ++++++ docker/stack/ownership-real/seed.sql | 48 ++++ includes/func.alt-arts.php | 78 ++++++ .../core-altered-cards/includes/functions.php | 3 + plugins/ownership/api/alt-art-pending.php | 32 +++ .../ownership/api/alt-art-preference-mode.php | 53 +--- plugins/ownership/api/alt-art-search.php | 11 - plugins/ownership/assets/style.css | 13 +- plugins/ownership/includes/functions.php | 8 +- plugins/ownership/includes/subnav.php | 4 +- plugins/ownership/pages/alt-arts.php | 117 +------- plugins/ownership/pages/ownership.php | 8 +- plugins/ownership/plugin.json | 5 +- plugins/rebuilder/README.md | 4 +- plugins/rebuilder/app/design/COMPONENTS.md | 3 +- plugins/rebuilder/app/docs/backend-api.md | 4 +- plugins/rebuilder/app/docs/legacy-parity.md | 6 +- .../app/src/app/core/alt-art-defaults.spec.ts | 109 ++++++++ .../app/src/app/core/alt-art-defaults.ts | 137 ++++++++++ .../app/src/app/core/deck-store.spec.ts | 94 +++++-- .../rebuilder/app/src/app/core/deck-store.ts | 138 ++++++---- plugins/rebuilder/app/src/app/core/models.ts | 2 + .../app/core/ownership-api.service.spec.ts | 52 ++-- .../app/src/app/core/ownership-api.service.ts | 77 ++---- .../features/deck/deck-board/deck-board.html | 4 +- .../features/deck/deck-board/deck-board.ts | 19 +- .../decks/equinox/equinox-import.spec.ts | 38 ++- .../features/decks/equinox/equinox-import.ts | 16 +- .../alt-art-picker.overlay.html | 29 ++ .../alt-art-picker.overlay.scss | 131 +++++++++ .../alt-art-picker/alt-art-picker.overlay.ts | 116 ++++++++ .../editor/deck-list-view/deck-list-view.html | 9 +- .../editor/deck-list-view/deck-list-view.scss | 5 + .../editor/deck-list-view/deck-list-view.ts | 11 +- .../editor/deck-panel/deck-panel.html | 2 +- .../editor/deck-preview/deck-preview.html | 4 +- .../editor/deck-preview/deck-preview.ts | 19 +- .../features/editor/editor-alt-arts.spec.ts | 92 +++++++ .../app/features/editor/editor-alt-arts.ts | 254 +++++++++++++++--- .../editor/editor-page/editor.page.html | 5 +- .../editor/editor-page/editor.page.ts | 8 +- .../editor/token-arts/token-arts.overlay.html | 29 +- .../editor/token-arts/token-arts.overlay.scss | 86 +++++- .../editor/token-arts/token-arts.overlay.ts | 78 +++++- .../search/card-search/card-search.html | 6 + .../search/card-search/card-search.ts | 8 +- .../search/search-results/search-results.html | 19 +- .../search/search-results/search-results.ts | 39 ++- .../shared/alt-art-slots/alt-art-slots.html | 23 -- .../shared/alt-art-slots/alt-art-slots.scss | 74 ----- .../alt-art-slots/alt-art-slots.spec.ts | 94 ------- .../shared/alt-art-slots/alt-art-slots.ts | 94 ------- .../shared/card-zoom/card-zoom.overlay.html | 27 +- .../shared/card-zoom/card-zoom.overlay.scss | 37 --- .../shared/card-zoom/card-zoom.overlay.ts | 57 +--- .../hero-picker/hero-picker.overlay.html | 14 +- .../shared/hero-picker/hero-picker.overlay.ts | 7 +- .../src/app/features/shared/new-deck.form.ts | 5 +- .../src/app/features/shared/new-deck.spec.ts | 3 +- .../shared/new-deck/new-deck.overlay.html | 16 +- .../shared/new-deck/new-deck.overlay.ts | 4 - plugins/rebuilder/app/src/app/ui/icon/icon.ts | 3 + .../app/ui/metier/card-pile/card-pile.html | 5 + .../app/ui/metier/card-pile/card-pile.scss | 31 +++ .../src/app/ui/metier/card-pile/card-pile.ts | 5 + .../app/ui/metier/card-tile/card-tile.html | 12 +- .../app/ui/metier/card-tile/card-tile.scss | 54 ++++ .../src/app/ui/metier/card-tile/card-tile.ts | 6 + .../rebuilder/app/src/locale/messages.en.json | 37 ++- plugins/rebuilder/e2e/rebuilder.spec.ts | 180 +++++++++---- sql/migration_009_alt_art_pending_decks.sql | 9 + sql/schema.sql | 11 + 78 files changed, 2116 insertions(+), 954 deletions(-) create mode 100644 docker-compose.ownership.yml create mode 100644 docker/stack/ownership-real/config.php create mode 100644 docker/stack/ownership-real/keycloak-scopes.sh create mode 100644 docker/stack/ownership-real/seed-decks.php create mode 100644 docker/stack/ownership-real/seed.sql create mode 100644 includes/func.alt-arts.php create mode 100644 plugins/ownership/api/alt-art-pending.php create mode 100644 plugins/rebuilder/app/src/app/core/alt-art-defaults.spec.ts create mode 100644 plugins/rebuilder/app/src/app/core/alt-art-defaults.ts create mode 100644 plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.html create mode 100644 plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.scss create mode 100644 plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.ts create mode 100644 plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.spec.ts delete mode 100644 plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.html delete mode 100644 plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.scss delete mode 100644 plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.spec.ts delete mode 100644 plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.ts create mode 100644 sql/migration_009_alt_art_pending_decks.sql diff --git a/README.md b/README.md index cc716b03..5fd54524 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`). +**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 starts in the old « Global » mode, with a deck to see its switch): + +```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/docker-compose.ownership.yml b/docker-compose.ownership.yml new file mode 100644 index 00000000..3896f652 --- /dev/null +++ b/docker-compose.ownership.yml @@ -0,0 +1,108 @@ +# Local testing of the alt arts with the real AlteredOwnership service (Altered-Re-Union/AlteredOwnership) instead of +# the mock (docker/stack/ownership-mock, which stays the collection API). On top of the full stack: +# +# docker compose -f docker-compose.yml -f docker-compose.stack.yml -f docker-compose.ownership.yml up -d --build --wait +# +# docker/stack/ownership-real/seed.sql gives alice and bob owned alt arts and default alt arts, and alice the « Global » +# mode (to try its switch to « par deck »); seed-decks.php a deck of hers built in that mode and one of bob's; +# keycloak-scopes.sh the `read-collection` scope the service requires in the site's tokens (sign in again after a first +# start). Service: http://localhost:8003 (health: /health). To try the switch again: +# docker compose -f docker-compose.yml -f docker-compose.stack.yml -f docker-compose.ownership.yml run --rm ownership-seed +# docker compose -f docker-compose.yml -f docker-compose.stack.yml -f docker-compose.ownership.yml run --rm alice-decks +services: + web: + volumes: + - ./docker/stack/ownership-real/config.php:/var/www/html/config.local.php:ro + depends_on: + ownership-seed: + condition: service_completed_successfully + + ownership-db: + image: postgres:16-alpine + environment: + POSTGRES_USER: ownership + POSTGRES_PASSWORD: ownership + POSTGRES_DB: ownershipdb + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ownership -d ownershipdb"] + interval: 3s + timeout: 3s + retries: 30 + + ownership-redis: + image: redis:7-alpine + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 3s + timeout: 3s + retries: 30 + + ownership-real: + # Pinned: bump the commit to follow the service. + image: alteredcore-stack-ownership + build: + context: https://github.com/Altered-Re-Union/AlteredOwnership.git#41e6248e643ccdb1161775cdec921441a8db91ec + dockerfile: build/Dockerfile + target: app + environment: + # Development: plain HTTP to Keycloak, /health mapped. The schema and the alt-art catalog come from its migrations. + ASPNETCORE_ENVIRONMENT: Development + ConnectionStrings__ownershipdb: Host=ownership-db;Database=ownershipdb;Username=ownership;Password=ownership + ConnectionStrings__cache: ownership-redis:6379 + Keycloak__Authority: http://auth.altered.local.gd:18080/realms/players + Keycloak__ClientId: main-site + Keycloak__ClientSecret: dev-main-site-secret + ExternalHosts__AuthBase: http://auth.altered.local.gd:18080 + ports: + - "127.0.0.1:8003:8080" + depends_on: + ownership-db: + condition: service_healthy + ownership-redis: + condition: service_healthy + keycloak: + condition: service_healthy + healthcheck: + # The image has no curl: HTTP over bash. + test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8080 && printf 'GET /health HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q 'Healthy' <&3"] + interval: 5s + timeout: 5s + retries: 60 + + keycloak-scopes: + image: quay.io/keycloak/keycloak:26.0 + entrypoint: ["/bin/bash", "/setup/keycloak-scopes.sh"] + environment: + # kcadm is a plain JVM: JDK 21 dies with SIGILL on recent Apple Silicon (SVE), as the Keycloak service. + JAVA_TOOL_OPTIONS: "-XX:+IgnoreUnrecognizedVMOptions -XX:UseSVE=0" + volumes: + - ./docker/stack/ownership-real:/setup:ro + depends_on: + keycloak: + condition: service_healthy + + ownership-seed: + image: postgres:16-alpine + entrypoint: ["psql", "-v", "ON_ERROR_STOP=1", "-f", "/setup/seed.sql"] + environment: + PGHOST: ownership-db + PGUSER: ownership + PGPASSWORD: ownership + PGDATABASE: ownershipdb + volumes: + - ./docker/stack/ownership-real:/setup:ro + depends_on: + ownership-real: + condition: service_healthy + keycloak-scopes: + condition: service_completed_successfully + + alice-decks: + image: alteredcore-stack-web + entrypoint: ["php", "/var/www/html/docker/stack/ownership-real/seed-decks.php"] + volumes: + - .:/var/www/html + - ./docker/stack/ownership-real/config.php:/var/www/html/config.local.php:ro + depends_on: + web: + condition: service_healthy diff --git a/docker/stack/config.stack.php b/docker/stack/config.stack.php index 4bf04228..d3a0ecd3 100644 --- a/docker/stack/config.stack.php +++ b/docker/stack/config.stack.php @@ -35,8 +35,9 @@ define('DECKS_API_URL', 'http://decks-api:80'); define('CDN_URL', 'https://cdn.alteredcore.org'); // Mock of the digital-ownership service (docker/stack/ownership-mock): alt arts in the site and Re:Builder. It also -// answers the collection API's GET /api/collection (the real one only takes altered.re tokens). -define('OWNERSHIP_API_URL', 'http://ownership:8080'); +// answers the collection API's GET /api/collection (the real one only takes altered.re tokens). The real service +// (docker-compose.ownership.yml) defines it first. +if (!defined('OWNERSHIP_API_URL')) define('OWNERSHIP_API_URL', 'http://ownership:8080'); define('OWNERSHIP_WEB_URL', ''); define('COLLECTION_API_URL', 'http://ownership:8080'); define('COLLECTION_USE_API', false); diff --git a/docker/stack/ownership-mock/index.php b/docker/stack/ownership-mock/index.php index e6617e61..dfac5538 100644 --- a/docker/stack/ownership-mock/index.php +++ b/docker/stack/ownership-mock/index.php @@ -157,7 +157,10 @@ function owned(array $print, string $user): ?int { function options(array $f, string $user): array { $chosen = state($user)['slots'][$f['key']] ?? []; $slots = []; - for ($i = 0; $i < $f['copies']; $i++) $slots[] = ['slotIndex' => $i, 'reference' => $chosen[$i] ?? $f['prints'][0]['reference']]; + // As the service: slots numbered from 1, the plain print where the player chose nothing. + for ($i = 0; $i < $f['copies']; $i++) { + $slots[] = ['slotIndex' => $i + 1, 'reference' => $chosen[$i] ?? $f['prints'][0]['reference'], 'isExplicitChoice' => isset($chosen[$i])]; + } return key3($f) + [ 'options' => array_map(fn($p) => ['reference' => $p['reference'], 'ownedQuantity' => owned($p, $user)], $f['prints']), 'slots' => $slots, diff --git a/docker/stack/ownership-real/config.php b/docker/stack/ownership-real/config.php new file mode 100644 index 00000000..a386cb97 --- /dev/null +++ b/docker/stack/ownership-real/config.php @@ -0,0 +1,5 @@ +/dev/null + +# The image has no awk nor grep: bash reads the CSV. +scope= +while IFS=, read -r id name; do + if [ "$name" = read-collection ]; then scope=$id; fi +done < <($kc get client-scopes -r players --fields id,name --format csv --noquotes) +if [ -z "$scope" ]; then + scope=$($kc create client-scopes -r players -i -s name=read-collection -s protocol=openid-connect \ + -s 'attributes."include.in.token.scope"=true' -s 'attributes."display.on.consent.screen"=false') +fi +client=$($kc get clients -r players -q clientId=main-site --fields id --format csv --noquotes) +$kc update "clients/$client/default-client-scopes/$scope" -r players +echo "read-collection is a default scope of main-site" diff --git a/docker/stack/ownership-real/seed-decks.php b/docker/stack/ownership-real/seed-decks.php new file mode 100644 index 00000000..632c9190 --- /dev/null +++ b/docker/stack/ownership-real/seed-decks.php @@ -0,0 +1,79 @@ + $method, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 20]); + if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + $res = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return [$code, is_string($res) ? $res : '']; +} + +/** Bearer headers of a player of the stack's realm; exits when Keycloak refuses. */ +function playerAuth(string $user): array { + [$code, $body] = seedHttp('POST', KC_URL . '/realms/' . KC_REALM . '/protocol/openid-connect/token', + ['Content-Type: application/x-www-form-urlencoded'], + http_build_query(['grant_type' => 'password', 'client_id' => KC_CLIENT_ID, 'client_secret' => KC_CLIENT_SECRET, + 'username' => $user, 'password' => 'TestPassword1234', 'scope' => 'openid'])); + $token = json_decode($body, true)['access_token'] ?? null; + if ($code !== 200 || !$token) { + fwrite(STDERR, "[alt-art-decks] no token for {$user} (HTTP {$code})\n"); + exit(1); + } + return ['Authorization: Bearer ' . $token, 'Accept: application/json', 'Content-Type: application/json']; +} +$api = rtrim(DECKS_API_URL, '/'); + +$line = fn(string $ref, int $qty) => ['cardReference' => $ref, 'quantity' => $qty]; +$decks = [ + [ + // Plain prints only: her default alt arts replace them (Vaike, Icare, Fée Clochette, the hero). + 'name' => 'Axiom (arts de base)', + 'deckCards' => [ + $line('ALT_ALIZE_B_AX_01_C', 1), // Sierra & Oddball + $line('ALT_ALIZE_B_AX_35_C', 3), // Vaike, l'Énergéticienne + $line('ALT_BISE_B_AX_56_C', 3), // Icare + $line('ALT_CORE_B_AX_09_C', 3), // Fée Clochette + $line('ALT_CORE_B_AX_22_C', 3), // Entraînement Mécanique + $line('ALT_CYCLONE_B_AX_74_C', 2), // Lucan, Léviathan Affamé + $line('ALT_CYCLONE_B_AX_76_C', 2), // Sceau Axiom + $line('ALT_EOLE_B_AX_106_C', 1), // Salamandre Furtive + $line('ALT_ALIZE_B_AX_46_C', 2), // Galeries Saisies par les Glaces + $line('ALT_CORE_B_AX_08_C', 3), // Récupérateur Axiom (one illustration) + ], + ], + [ + // Prints chosen in the deck: one Vaike on the plain art, an alt art not among her defaults. + 'name' => 'Axiom (arts choisis)', + 'deckCards' => [ + $line('ALT_ALIZE_B_AX_01_C', 1), + $line('ALT_ALIZE_A_AX_35_C', 2), + $line('ALT_ALIZE_B_AX_35_C', 1), + $line('ALT_CORE_A_AX_22_C', 1), + $line('ALT_CORE_B_AX_22_C', 2), + $line('ALT_CORE_B_AX_09_C', 2), + ], + ], +]; +$owners = ['alice', 'bob']; +$created = 0; +foreach ($decks as $i => $deck) { + $auth = playerAuth($owners[$i]); + [, $body] = seedHttp('GET', $api . '/api/decks?itemsPerPage=1000', $auth); + $mine = json_decode($body, true); + foreach (is_array($mine) ? ($mine['member'] ?? $mine) : [] as $existing) { + if (($existing['name'] ?? '') === $deck['name']) seedHttp('DELETE', $api . '/api/decks/' . rawurlencode($existing['id']), $auth); + } + $deck += ['format' => 'standard', 'isPublic' => false, 'isDraft' => true]; + [$code] = seedHttp('POST', $api . '/api/decks', $auth, json_encode($deck, JSON_UNESCAPED_UNICODE)); + if ($code === 201) $created++; + else fwrite(STDERR, "[alt-art-decks] {$deck['name']}: HTTP {$code}\n"); +} +echo "[alt-art-decks] {$created} decks created\n"; diff --git a/docker/stack/ownership-real/seed.sql b/docker/stack/ownership-real/seed.sql new file mode 100644 index 00000000..2406cfac --- /dev/null +++ b/docker/stack/ownership-real/seed.sql @@ -0,0 +1,48 @@ +-- docker-compose.ownership.yml: the players of the stack's realm (docker/stack/players-realm.json) in the real +-- AlteredOwnership service, once its migrations ran. Idempotent: run on every start, resets their mode, ownership and +-- default alt arts. +-- +-- alice: « Global » mode (the site switches it to « par deck » on her next visit and marks her decks to receive her +-- default alt arts). bob: « par deck » already. Both own the same Axiom alt arts, in various quantities, and have the +-- same default alt arts. +-- Most MUSUBI prints (Fée Clochette, Prestidigitatrice de l'Ouroboros, Le Roi-Grenouille…) are everyone's in unlimited +-- quantity: they need no ownership row. + +INSERT INTO "Users" ("Id", "KeycloakId", "CreatedAt", "AltArtPreferenceMode") +VALUES ('aaaaaaaa-0000-0000-0000-000000000001', '11111111-1111-1111-1111-111111111111', now(), 'Global'), + ('aaaaaaaa-0000-0000-0000-000000000002', '22222222-2222-2222-2222-222222222222', now(), 'PerDeck') +ON CONFLICT ("KeycloakId") DO UPDATE SET "AltArtPreferenceMode" = EXCLUDED."AltArtPreferenceMode"; + +DELETE FROM "CardOwnerships" WHERE "UserId" IN ('aaaaaaaa-0000-0000-0000-000000000001', 'aaaaaaaa-0000-0000-0000-000000000002'); +INSERT INTO "CardOwnerships" ("UserId", "CardReference", "Quantity", "IsUnique") +SELECT u.id, ref, qty, false +FROM (VALUES ('aaaaaaaa-0000-0000-0000-000000000001'::uuid), ('aaaaaaaa-0000-0000-0000-000000000002'::uuid)) AS u(id), +(VALUES + ('ALT_ALIZE_A_AX_35_C', 2), -- Vaike, l'Énergéticienne: 2 of 3 copies + ('ALT_BISE_A_AX_56_C', 3), -- Icare: a full playset + ('ALT_CORE_A_AX_22_C', 1), -- Entraînement Mécanique: 1 copy + ('ALT_CYCLONE_A_AX_74_C', 2), -- Lucan, Léviathan Affamé + ('ALT_CYCLONE_A_AX_76_C', 1), -- Sceau Axiom + ('ALT_EOLECB_A_AX_106_C', 1), -- Salamandre Furtive (Evil Eye) + ('ALT_ALIZE_A_AX_46_C', 3), -- Galeries Saisies par les Glaces + ('ALT_CORE_P_AX_01_C', 1), -- Sierra & Oddball, promo hero + ('ALT_WCS25_P_AX_01_C', 1), -- Sierra & Oddball, WCS 2025 hero + ('ALT_WCS26_A_AX_31_C', 1), -- Scarabot token, WCS 2026 + ('ALT_MUSUBI_B_AX_30_R1', 1) -- Ruche des Scarabots, MUSUBI 2.0 (a tracked MUSUBI print) +) AS owned(ref, qty); + +-- Default alt arts (1st, 2nd, 3rd copy). Fée Clochette: the MUSUBI print for the first two copies. +DELETE FROM "UserCardArtPreferences" WHERE "UserId" IN ('aaaaaaaa-0000-0000-0000-000000000001', 'aaaaaaaa-0000-0000-0000-000000000002'); +INSERT INTO "UserCardArtPreferences" ("UserId", "FamilyId", "Faction", "Rarity", "SlotIndex", "PreferredReference") +SELECT u.id, f.family, f.faction, f.rarity, f.slot, f.ref +FROM (VALUES ('aaaaaaaa-0000-0000-0000-000000000001'::uuid), ('aaaaaaaa-0000-0000-0000-000000000002'::uuid)) AS u(id), +(VALUES + (64, 'AX', 'C', 1, 'ALT_ALIZE_A_AX_35_C'), + (64, 'AX', 'C', 2, 'ALT_ALIZE_A_AX_35_C'), + (165, 'AX', 'C', 1, 'ALT_BISE_A_AX_56_C'), + (165, 'AX', 'C', 2, 'ALT_BISE_A_AX_56_C'), + (165, 'AX', 'C', 3, 'ALT_BISE_A_AX_56_C'), + (304, 'AX', 'C', 1, 'ALT_MUSUBI_B_AX_09_C'), + (304, 'AX', 'C', 2, 'ALT_MUSUBI_B_AX_09_C'), + (69, 'AX', 'C', 1, 'ALT_CORE_P_AX_01_C') +) AS f(family, faction, rarity, slot, ref); diff --git a/includes/func.alt-arts.php b/includes/func.alt-arts.php new file mode 100644 index 00000000..5e1f9108 --- /dev/null +++ b/includes/func.alt-arts.php @@ -0,0 +1,78 @@ + 'PerDeck']); + if ($status !== 204) return false; + + if ($ids) { + $insert = getDB()->prepare(q("INSERT IGNORE INTO {alt_art_pending_decks} (user_id, deck_id) VALUES (:u, :d)")); + foreach ($ids as $id) $insert->execute([':u' => $userId, ':d' => $id]); + } + return true; +} + +/** Whether $deckId (a deck of $userId) waits for its default alt arts. */ +function altArtDeckPending(int $userId, string $deckId): bool { + $stmt = getDB()->prepare(q("SELECT 1 FROM {alt_art_pending_decks} WHERE user_id = :u AND deck_id = :d LIMIT 1")); + $stmt->execute([':u' => $userId, ':d' => $deckId]); + return (bool)$stmt->fetchColumn(); +} + +/** $deckId took its default alt arts (or does not need them any more). */ +function altArtClearPending(int $userId, string $deckId): void { + getDB()->prepare(q("DELETE FROM {alt_art_pending_decks} WHERE user_id = :u AND deck_id = :d")) + ->execute([':u' => $userId, ':d' => $deckId]); +} + +/** Ids of the decks of the token's player (decks API, every deck of the account in one page); `null` on an error. */ +function altArtUserDeckIds(string $token): ?array { + if (!defined('DECKS_API_URL') || !DECKS_API_URL) return []; + [$status, $body] = altArtHttp('GET', rtrim(DECKS_API_URL, '/') . '/api/decks?' . http_build_query(['page' => 1, 'itemsPerPage' => 1000]), $token); + if ($status !== 200 || !is_array($body)) return null; + $decks = isset($body['member']) && is_array($body['member']) ? $body['member'] : $body; + $ids = []; + foreach ($decks as $deck) { + if (is_array($deck) && isset($deck['id']) && is_string($deck['id'])) $ids[] = $deck['id']; + } + return $ids; +} + +/** JSON request with a bearer token: [status (0 when unreachable), decoded body or null]. */ +function altArtHttp(string $method, string $url, string $token, ?array $body = null): array { + $headers = ['Authorization: Bearer ' . $token, 'Accept: application/json']; + if ($body !== null) $headers[] = 'Content-Type: application/json'; + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 20, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + ]); + if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body)); + $raw = curl_exec($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + if ($raw === false) return [0, null]; + $decoded = json_decode((string)$raw, true); + return [$status, is_array($decoded) ? $decoded : null]; +} diff --git a/plugins/core-altered-cards/includes/functions.php b/plugins/core-altered-cards/includes/functions.php index 250be444..4e8571a0 100644 --- a/plugins/core-altered-cards/includes/functions.php +++ b/plugins/core-altered-cards/includes/functions.php @@ -277,6 +277,9 @@ function cacGetAltArtPreferenceMode(int $userId): string { if (is_array($data) && in_array($data['mode'] ?? null, ['PerDeck', 'Global'], true)) { $mode = $data['mode']; } + // Default alt arts replace the "Global" mode: a player still in it is switched once. + require_once dirname(__DIR__, 3) . '/includes/func.alt-arts.php'; + if ($mode === 'Global' && altArtLeaveGlobalMode($userId)) $mode = 'PerDeck'; } $_SESSION['alt_art_mode'] = $mode; diff --git a/plugins/ownership/api/alt-art-pending.php b/plugins/ownership/api/alt-art-pending.php new file mode 100644 index 00000000..b05d5100 --- /dev/null +++ b/plugins/ownership/api/alt-art-pending.php @@ -0,0 +1,32 @@ + → {"pending": bool} +// POST /papi/ownership/alt-art-pending {"deck": ""} → 204 +// Re:Builder, on opening one of the player's decks: whether it waits for the player's default alt arts (decks of a +// player switched from the « Global » alt-art mode, see includes/func.alt-arts.php), then that it took them. Reading +// the mode first (from the service, not the session's cache) switches a player still in « Global » (the deck then waits +// too). Manifest: "auth": "user". +require_once __DIR__ . '/../includes/functions.php'; +require_once dirname(__DIR__, 3) . '/includes/func.alt-arts.php'; + +header('Content-Type: application/json; charset=UTF-8'); +header('Cache-Control: private, no-store'); + +$userId = (int)($_SESSION['user_id'] ?? 0); +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$deckId = $method === 'GET' ? ($_GET['deck'] ?? '') : (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; +} + +if ($method === 'POST') { + altArtClearPending($userId, $deckId); + http_response_code(204); + return; +} + +// Read again, not from the session's cache: a player still in « Global » is switched before their deck opens. +unset($_SESSION['alt_art_mode'], $_SESSION['alt_art_mode_at']); +ownGetAltArtPreferenceMode($userId); +echo json_encode(['pending' => altArtDeckPending($userId, $deckId)]); diff --git a/plugins/ownership/api/alt-art-preference-mode.php b/plugins/ownership/api/alt-art-preference-mode.php index 39725e6d..3bf3c989 100644 --- a/plugins/ownership/api/alt-art-preference-mode.php +++ b/plugins/ownership/api/alt-art-preference-mode.php @@ -1,8 +1,8 @@ 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; -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]); - -if ($status !== 204) { - http_response_code($status ?: 502); - echo json_encode(['error' => 'Ownership API error']); - exit; -} - -// Refresh the cache immediately so the rest of this session sees the new mode right -// away instead of waiting out ALT_ART_MODE_CACHE_TTL. -$_SESSION['alt_art_mode'] = $mode; -$_SESSION['alt_art_mode_at'] = time(); - -http_response_code(204); +echo json_encode(['mode' => ownGetAltArtPreferenceMode((int)($_SESSION['user_id'] ?? 0))]); 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 5837bc56..57a9d875 100644 --- a/plugins/ownership/assets/style.css +++ b/plugins/ownership/assets/style.css @@ -419,7 +419,7 @@ margin: var(--ac-space-3) 0; } -/* 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; @@ -437,17 +437,8 @@ 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 { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--ac-space-2); -} +/* Search field (pages/alt-arts.php) */ .own-aa-search { max-width: 260px; } diff --git a/plugins/ownership/includes/functions.php b/plugins/ownership/includes/functions.php index 284ce117..08901948 100644 --- a/plugins/ownership/includes/functions.php +++ b/plugins/ownership/includes/functions.php @@ -64,9 +64,9 @@ function ownApiRequestRaw(string $method, string $path, int $userId, $body = nul * The player's alt-art preference mode ("PerDeck", the default, or "Global") — see * AltArtPreferenceMode on the ownership service. Cached in the session for * ALT_ART_MODE_CACHE_TTL seconds so pages that need it (alt-arts.php, the deckbuilder, - * the card-detail modal on every page) don't each cost a round trip; refreshed - * immediately whenever the mode is changed (see api/alt-art-preference-mode.php) rather - * than waiting out the TTL. + * the card-detail modal on every page) don't each cost a round trip. A player still in + * "Global" is switched to "PerDeck" here, once (see includes/func.alt-arts.php): default + * alt arts replace the mode. */ const ALT_ART_MODE_CACHE_TTL = 300; @@ -82,6 +82,8 @@ function ownGetAltArtPreferenceMode(int $userId): string { if (is_array($data) && in_array($data['mode'] ?? null, ['PerDeck', 'Global'], true)) { $mode = $data['mode']; } + require_once dirname(__DIR__, 3) . '/includes/func.alt-arts.php'; + if ($mode === 'Global' && altArtLeaveGlobalMode($userId)) $mode = 'PerDeck'; } $_SESSION['alt_art_mode'] = $mode; 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/pages/alt-arts.php b/plugins/ownership/pages/alt-arts.php index ea4032f5..11b1433b 100644 --- a/plugins/ownership/pages/alt-arts.php +++ b/plugins/ownership/pages/alt-arts.php @@ -1,8 +1,9 @@ '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…', - '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.', + 'intro' => 'Choose the illustration each copy of a card takes when you add it to a deck (1st, 2nd and 3rd copy). In Re:Builder, the brush of a card changes its illustrations for one deck, and « Default alt arts » applies these choices to a deck again. A token\'s illustration is the same in all your decks.', ], 'fr' => [ 'unavailable' => 'Le service de propriété numérique n\'est pas configuré sur ce site.', @@ -58,18 +48,7 @@ '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…', - '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.', + 'intro' => 'Choisissez l\'illustration que prend chaque exemplaire d\'une carte quand vous l\'ajoutez à un deck (1er, 2e et 3e exemplaire). Dans Re:Builder, le pinceau d\'une carte change ses illustrations pour un deck, et « Arts par défaut » réapplique ces choix à un deck. L\'illustration d\'un jeton est la même dans tous vos decks.', ], ][getUiLang()]; @@ -77,9 +56,8 @@ $ownLoggedIn = kcIsLoggedIn(); $ownActiveTab = 'alt-arts'; -$userId = (int)($_SESSION['user_id'] ?? 0); -$altArtMode = ($ownEnabled && $ownLoggedIn) ? ownGetAltArtPreferenceMode($userId) : 'PerDeck'; -$isGlobalMode = $altArtMode === 'Global'; +// Reading the mode switches a player still in « Global » to « par deck » (default alt arts replace it). +if ($ownEnabled && $ownLoggedIn) ownGetAltArtPreferenceMode((int)($_SESSION['user_id'] ?? 0)); $cacDir = dirname(__DIR__, 2) . '/core-altered-cards'; $cacAvailable = is_file($cacDir . '/includes/functions.php'); @@ -100,13 +78,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,38 +101,7 @@ .filter-toggle below come from it. --> -
-
- - -
- -
- - -
- - - +

@@ -232,49 +172,12 @@ window.OWN_AA_CONFIG = { searchUrl: , setPreferenceUrl: , - preferenceModeUrl: , baseUrl: , cdnUrl: , lang: , markerImg: , csrfToken: , - isGlobalMode: , }; - (function () { - var cfg = window.OWN_AA_CONFIG; - var t = function (key, fallback) { - var dict = (window.OWN_I18N || {})[document.documentElement.lang] || {}; - return dict[key] || fallback; - }; - - var infoBtn = document.getElementById('own-aa-mode-info-btn'); - var infoModal = document.getElementById('own-aa-mode-info-modal'); - var infoClose = document.getElementById('own-aa-mode-info-close'); - infoBtn?.addEventListener('click', function () { infoModal.hidden = false; }); - infoClose?.addEventListener('click', function () { infoModal.hidden = true; }); - infoModal?.addEventListener('click', function (e) { if (e.target === infoModal) infoModal.hidden = true; }); - - var perDeckBtn = document.getElementById('own-aa-mode-perdeck'); - var globalBtn = document.getElementById('own-aa-mode-global'); - var setMode = function (mode) { - if ((mode === 'Global') === cfg.isGlobalMode) return; - [perDeckBtn, globalBtn].forEach(function (b) { b && (b.disabled = true); }); - fetch(cfg.preferenceModeUrl, { - method: 'PUT', - credentials: 'same-origin', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ mode: mode, csrf_token: cfg.csrfToken }), - }).then(function (res) { - if (!res.ok) throw new Error('save failed'); - location.reload(); - }).catch(function () { - [perDeckBtn, globalBtn].forEach(function (b) { b && (b.disabled = false); }); - alert(t('modeSaveError', 'Could not save your setting.')); - }); - }; - perDeckBtn?.addEventListener('click', function () { setMode('PerDeck'); }); - globalBtn?.addEventListener('click', function () { setMode('Global'); }); - })(); 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..2a4e7038 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"]}, + {"endpoint": "alt-art-pending", "file": "api/alt-art-pending.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 2babe668..1cb2295f 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) ; `note: string | null` : étiquette au-dessus des contrôles, visuel encadré (art alternatif possédé dans la recherche : « 2 possédées ») | `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 ») ; `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..3d164a44 100644 --- a/plugins/rebuilder/app/docs/backend-api.md +++ b/plugins/rebuilder/app/docs/backend-api.md @@ -135,10 +135,10 @@ 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), `alt-art-pending` (`GET ?deck=`: the deck of a player switched from the « Global » mode waits for their default alt arts; `POST {deck}`: it took them). Token illustrations are saved through the relay (`PUT /api/alt-arts/preferences`). ## Local stack diff --git a/plugins/rebuilder/app/docs/legacy-parity.md b/plugins/rebuilder/app/docs/legacy-parity.md index 84e1ecdb..13f6c42c 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) ; le widget et le tilt du mode Global disparaissent avec ce mode | | 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 | ✅ Arts par défaut à la place du mode Global : 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, « Arts alternatifs » (recherche) montre les arts possédés (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..df9c32cf --- /dev/null +++ b/plugins/rebuilder/app/src/app/core/alt-art-defaults.spec.ts @@ -0,0 +1,109 @@ +import { addedCopyPrint, defaultPrints, familyPrints, linesWithDefaults, removedCopyPrint, sameCopies, withFamilyPrints } 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('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..c507cb84 --- /dev/null +++ b/plugins/rebuilder/app/src/app/core/alt-art-defaults.ts @@ -0,0 +1,137 @@ +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). */ +export 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); +} + +/** + * 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[] { + const slots = slotPrints(choice); + const base = basePrint(choice); + const used = new Map(); + const out: string[] = []; + for (let i = 0; i < count; i++) { + 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); + out.push(print); + } + return out; +} + +/** + * 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. */ +export 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-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 9989a25d..91c57f58 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, @@ -246,7 +247,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`, @@ -332,25 +334,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); @@ -371,7 +401,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()); @@ -397,6 +438,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. @@ -413,9 +464,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])); @@ -831,43 +883,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..8d689868 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,24 @@ describe('OwnershipApiService', () => { env.ownershipApiUrl = ''; }); - it('reads the « Global » mode, and treats an error as per-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' }); + it('asks whether a deck waits for the default alt arts, and treats an error as no', async () => { + const env2 = environment as { siteUrl: string }; + env2.siteUrl = ''; + const pending = lastValueFrom(api.pendingDefaults('d1')); + const req = http.expectOne((r) => r.url === '/papi/ownership/alt-art-pending' && r.params.get('deck') === 'd1'); + req.flush({ pending: true }); + expect(await pending).toBe(true); + const failed = lastValueFrom(api.pendingDefaults('d1')); + http.expectOne((r) => r.url === '/papi/ownership/alt-art-pending').flush('no', { status: 401, statusText: 'Unauthorized' }); 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'); + it('tells the site a deck took its default alt arts', async () => { + const done = lastValueFrom(api.clearPendingDefaults('d1'), { defaultValue: undefined }); + const req = http.expectOne('/papi/ownership/alt-art-pending'); 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); + expect(req.request.body).toMatchObject({ deck: 'd1' }); + req.flush(null, { status: 204, statusText: 'No Content' }); + await done; }); }); 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..f0c43cde 100644 --- a/plugins/rebuilder/app/src/app/core/ownership-api.service.ts +++ b/plugins/rebuilder/app/src/app/core/ownership-api.service.ts @@ -4,11 +4,6 @@ import { Observable, catchError, map, of, tap } 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,28 +30,19 @@ 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 site = environment.siteUrl.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)), - ); - } - /** * Families and illustrations of `references`, by reference (none for a card with a single illustration), from the * site's `deck-alt-arts` (plugin core-altered-cards), which also finds the family of a reprint. @@ -61,7 +50,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 +65,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 +74,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( @@ -106,33 +95,23 @@ export class OwnershipApiService { .pipe(tap({ complete: () => this.version.update((v) => v + 1) })); } - /** 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)), + /** + * Whether the player's deck `deckId` waits for their default alt arts (decks of a player switched from the « Global » + * mode, site's `alt-art-pending`, plugin ownership); `false` on an error. + */ + pendingDefaults(deckId: string): Observable { + if (!this.baseUrl) return of(false); + return this.http.get<{ pending?: boolean }>(`${this.site}/papi/ownership/alt-art-pending`, { params: { deck: deckId } }).pipe( + map((res) => res?.pending === true), + 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); - } + /** The deck took its default alt arts: it no longer waits for them. */ + clearPendingDefaults(deckId: string): Observable { + // The site's CSRF token travels in the body, as for its other endpoints (`FavoritesService`). + return this.http + .post(`${this.site}/papi/ownership/alt-art-pending`, { deck: deckId, csrf_token: environment.siteCsrf }) + .pipe(catchError(() => of(undefined))); } - 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..97f3d28a 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 @@ -16,11 +16,13 @@

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

}
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 1892e5db..fad58d66 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,5 +1,5 @@ import { Component, DestroyRef, ElementRef, afterNextRender, computed, effect, inject, input, signal, untracked } from '@angular/core'; -import { DeckStore } from '../../../core/deck-store'; +import { DeckStore, displayName } from '../../../core/deck-store'; import type { Card, HydratedLine } from '../../../core/models'; import { AcCardPile } from '../../../ui/metier'; import { AcSkeleton } from '../../../ui/containers'; @@ -7,6 +7,7 @@ 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. */ @@ -31,7 +32,7 @@ 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); @@ -91,14 +92,24 @@ export class DeckBoard { return this.deck.quantities().get(card.reference) ?? 0; } + /** `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, + 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-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..98b8eaaf --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.html @@ -0,0 +1,29 @@ +
+

Touchez une illustration pour la poser sur l’exemplaire sélectionné.

+
    + @for (s of slots(); track $index) { +
  • + {{ s.label }} + +
  • + } +
+

Illustrations disponibles

+
    + @for (p of pool(); track p.reference) { +
  • + +
  • + } +
+
+ 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..f5f953ff --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.scss @@ -0,0 +1,131 @@ +.ac-overlay-body { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ac-space-3); +} +.hint { + margin: 0; + text-align: center; + font-size: var(--ac-font-size-small); + color: var(--ac-color-text-muted); +} +ul { + margin: 0; + padding: 0; + list-style: none; +} +/* The copies, large: up to 200 px each, narrower on a phone. */ +.slots { + width: 100%; + display: grid; + grid-template-columns: repeat(var(--copies, 1), minmax(0, 200px)); + justify-content: center; + gap: var(--ac-space-2); + padding: var(--ac-space-2); + border-radius: var(--ac-radius-lg); + background: var(--ac-color-primary-tint); + box-sizing: border-box; +} +.slots li { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ac-space-1); + min-width: 0; +} +.slot-label { + font-size: var(--ac-font-size-caption); + font-weight: 700; + color: var(--ac-color-text-muted); +} +.slot, +.print { + padding: 0; + border: 0; + background: none; + cursor: pointer; + font: inherit; + color: inherit; +} +.slot { + width: 100%; + border-radius: var(--ac-radius-md); + outline: 2px solid transparent; + outline-offset: 2px; +} +.slot img { + display: block; + width: 100%; + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: var(--ac-radius-md); +} +.slot.active { + outline-color: var(--ac-color-primary); +} +.pool-title { + margin: 0; + font-size: var(--ac-font-size-caption); + font-weight: 700; + color: var(--ac-color-text-muted); +} +/* The prints in one row, scrolled sideways when they do not fit. */ +.pool { + max-width: 100%; + display: flex; + justify-content: safe center; + gap: var(--ac-space-2); + overflow-x: auto; + padding-bottom: var(--ac-space-1); +} +.pool li { + flex-shrink: 0; +} +.print { + width: 126px; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ac-space-1); + padding: var(--ac-space-2) var(--ac-space-1) var(--ac-space-1); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-md); + background: var(--ac-color-surface); +} +.print img { + display: block; + width: 110px; + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: var(--ac-radius-sm); +} +.print.off { + cursor: not-allowed; +} +.print.off img { + opacity: 0.4; +} +.note { + font-size: var(--ac-font-size-caption); + color: var(--ac-color-text-muted); +} +.slot:focus-visible, +.print:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: 2px; +} +.settings { + margin-right: auto; + align-self: center; + font-size: var(--ac-font-size-small); + color: var(--ac-color-link); +} +@media (max-width: 767px) { + .print { + width: 96px; + } + .print img { + width: 84px; + } +} 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..8dec79a2 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.ts @@ -0,0 +1,116 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import { environment } from '../../../../environments/environment'; +import { counts, defaultPrints } 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'; + +export interface AltArtPickerData { + choice: AltArtChoice; + /** The family's copies in the deck, one print a copy. */ + prints: () => readonly string[]; + /** The family's copies take `prints`, one print a copy. */ + set: (prints: readonly string[]) => void; +} + +/** + * The brush of a card: the illustration of each of its copies in this deck. A touched illustration goes on the selected + * copy, then the next copy is selected; an illustration whose owned copies are all placed waits. « Arts par défaut » puts + * the player's default alt arts back. Each change is saved with the deck. + */ +@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!; + /** + * The copies, the ones on a default alt art in the order of the defaults (1st copy on the 1st default…) when the + * window opens; then each copy keeps its place while the illustrations change. + */ + protected readonly copies = signal(ordered(this.data.choice, this.data.prints())); + /** The selected copy: the next illustration touched goes on it. */ + protected readonly active = signal(0); + protected readonly slots = computed(() => this.copies().map((reference, i) => ({ reference, src: cardImageUrl(reference), label: copyLabel(i + 1) }))); + protected readonly pool = computed(() => { + const copies = this.copies(); + const active = this.active(); + return this.data.choice.options.options.map((o, i) => { + const placed = copies.filter((c) => c === o.reference).length; + // Taken by the other copies: the selected copy gives its own print back when it changes. + const elsewhere = copies.filter((c, j) => c === o.reference && j !== active).length; + const owned = o.ownedQuantity; + return { + reference: o.reference, + src: cardImageUrl(o.reference), + label: printLabel(i + 1), + off: owned !== null && elsewhere >= owned, + unowned: owned === 0, + note: owned === null ? unlimited() : owned === 0 ? notOwned() : placedLabel(placed, owned), + }; + }); + }); + protected readonly isDefault = computed(() => this.copies().join() === defaultPrints(this.data.choice, this.copies().length).join()); + /** The site's « Arts alternatifs par défaut » page (plugin ownership). */ + protected readonly settingsUrl = `${environment.siteUrl.replace(/\/$/, '')}/pages/ownership-alt-arts`; + + protected select(index: number): void { + this.active.set(index); + } + + protected place(reference: string, off: boolean): void { + const copies = this.copies(); + if (off || !copies.length) return; + const active = this.active(); + const next = copies.map((c, i) => (i === active ? reference : c)); + this.copies.set(next); + this.active.set((active + 1) % next.length); + this.data.set(next); + } + + protected resetToDefaults(): void { + const next = defaultPrints(this.data.choice, this.copies().length); + this.copies.set(next); + this.active.set(0); + this.data.set(next); + } +} + +/** `prints` with the copies on a default alt art first, in the order of the defaults, then the others. */ +function ordered(choice: AltArtChoice, prints: readonly string[]): string[] { + const left = counts(prints); + const out: string[] = []; + for (const d of defaultPrints(choice, prints.length)) { + const n = left.get(d) ?? 0; + if (!n) continue; + out.push(d); + left.set(d, n - 1); + } + for (const [print, n] of left) for (let i = 0; i < n; i++) out.push(print); + return out; +} + +const copyLabel = (n: number) => $localize`:@@altArt.marker:Exemplaire ${n}:n:`; +const printLabel = (n: number) => $localize`:@@altArt.tile:Illustration ${n}:n:`; +const unlimited = () => $localize`:@@editor.altArtPicker.unlimited:Illimitée`; +const notOwned = () => $localize`:@@editor.altArtPicker.notOwned:Non possédée`; +const placedLabel = (placed: number, owned: number) => + placed > 1 + ? $localize`:@@editor.altArtPicker.placedMany:${placed}:placed: / ${owned}:owned: placées` + : $localize`:@@editor.altArtPicker.placedOne:${placed}:placed: / ${owned}:owned: placée`; + +/** 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 be5ee0dc..aefb4e0f 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 @@ -30,8 +30,11 @@ -@if (!readonly() && altArts?.canChooseTokens()) { - +@if (!readonly() && altArts?.canChoose()) { +
+ + +
} @for (g of deck.groups(); track g.id) { @@ -39,7 +42,7 @@ (); readonly changeHero = output(); + /** A message for the page's notice (« Arts par défaut »). */ + readonly notice = output(); protected readonly statsOpen = signal(true); protected readonly formatLabel = computed(() => formatInfo(this.deck.format()).label); protected readonly legality = computed(() => editorLegality(this.deck)); @@ -37,4 +39,11 @@ export class DeckListView { protected chooseTokenArts(): void { openTokenArts(this.overlay); } + + protected applyDefaults(): void { + if (!this.altArts) return; + confirmAltArtDefaults(this.overlay, this.altArts).subscribe((message) => { + if (message) this.notice.emit(message); + }); + } } diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.html b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.html index 9e7c9f51..b7105476 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.html +++ b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.html @@ -15,7 +15,7 @@ appearance="panel" [card]="l.card" [quantity]="l.quantity" - [max]="deck.maxFor(l.card)" + [max]="altArts?.maxFor(l.card, false) ?? deck.maxFor(l.card)" [issues]="issues().get(l.card.reference) ?? noIssues" [warning]="altArts?.warnings()?.get(l.card.reference) ?? null" [readonly]="!deck.editable()" diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.html b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.html index 361cad6c..7ef94862 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.html +++ b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.html @@ -32,11 +32,13 @@ diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.ts b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.ts index e453222f..56cb7e71 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.ts +++ b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.ts @@ -1,5 +1,4 @@ import { Component, computed, inject, input, signal } from '@angular/core'; -import { DeckStore } from '../../../core/deck-store'; import { AcButton } from '../../../ui/buttons'; import { AcSkeleton } from '../../../ui/containers'; import { AcCardTile, AcDeckSection } from '../../../ui/metier'; @@ -7,7 +6,9 @@ import { AcOverlayService } from '../../../ui/overlay'; import { FavoritesService } from '../../../core/favorites.service'; import { EditorAltArts } from '../editor-alt-arts'; import type { Card } from '../../../core/models'; +import { DeckStore, displayName } from '../../../core/deck-store'; import { openCardZoom } from '../../shared/card-zoom/card-zoom.overlay'; +import { openAltArtPicker } from '../alt-art-picker/alt-art-picker.overlay'; /** « Aperçu » / « Cartes »: count line, « Tout replier », one section per type with tiles. */ @Component({ @@ -21,7 +22,7 @@ export class DeckPreview { private readonly overlay = inject(AcOverlayService); protected readonly favorites = inject(FavoritesService); /** 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); /** Three columns on compact (consultation « Cartes »). */ readonly dense = input(false); @@ -44,14 +45,24 @@ export class DeckPreview { /** A tile's visual: the card large, with its copies when the deck can be edited (the site's card lightbox). */ protected zoom(card: Card): void { const editable = !this.readonly() && this.deck.editable(); - const choice = this.altArts?.choiceFor(card.reference) ?? null; 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, - illustrations: editable && choice ? { choice, pick: (ref) => this.deck.swapReference(card, ref) } : undefined, + illustrations: editable ? (this.altArts?.pickerFor(card) ?? undefined) : undefined, }); } + /** `card` has a brush (several illustrations, the deck editable). */ + protected canIllustrate(card: Card): boolean { + return !this.readonly() && !!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); + } + toggleAll(): void { this.closed.set(this.allClosed() ? new Set() : new Set(this.deck.groups().map((g) => g.id))); } diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.spec.ts b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.spec.ts new file mode 100644 index 00000000..e8bb8463 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.spec.ts @@ -0,0 +1,92 @@ +import { TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { Subject, of } from 'rxjs'; +import { AuthSession } from '../../core/auth-session'; +import { DeckStore } from '../../core/deck-store'; +import type { Card, HydratedLine } from '../../core/models'; +import { OwnershipApiService, type AltArtChoice } from '../../core/ownership-api.service'; +import { EditorAltArts } from './editor-alt-arts'; + +const BASE = 'ALT_CORE_B_AX_04_C'; +const ALT = 'ALT_DUSTERTOP_P_AX_04_C'; +const NONE = 'ALT_CORE_A_AX_04_C'; +const kelon: AltArtChoice = { + family: { familyId: 302, faction: 'AX', rarity: 'C' }, + options: { + options: [ + { reference: BASE, ownedQuantity: null }, + { reference: ALT, ownedQuantity: 2 }, + { reference: NONE, ownedQuantity: 0 }, + ], + slots: [1, 2, 3].map((slotIndex) => ({ slotIndex, reference: ALT })), + }, +}; +const card = (reference: string): Card => ({ reference, name: 'Élémentaire de Kélon' }); + +describe('EditorAltArts', () => { + let answers: Subject>; + let lines: ReturnType>; + let familyAdds: number[]; + + function setup(): EditorAltArts { + answers = new Subject(); + lines = signal([]); + familyAdds = []; + TestBed.configureTestingModule({ + providers: [ + EditorAltArts, + { provide: AuthSession, useValue: { isLoggedIn: () => true } }, + { provide: OwnershipApiService, useValue: { baseUrl: '/ownership', altArtChoices: () => answers, pendingDefaults: () => of(false) } }, + { + provide: DeckStore, + useValue: { + lines, + hero: signal(null), + editable: signal(true), + deckId: signal('d1'), + isGuest: signal(false), + maxFor: () => 3, + quantityOf: (ref: string) => lines().find((l) => l.card.reference === ref)?.quantity ?? 0, + setQuantity: () => undefined, + setFamilyQuantity: (_card: Card, _choice: AltArtChoice, _members: Set, n: number) => familyAdds.push(n), + }, + }, + ], + }); + return TestBed.inject(EditorAltArts); + } + + it('waits for a card’s family before adding a copy, so that it takes its default alt art', () => { + const alt = setup(); + alt.request([BASE]); + alt.setQuantity(card(BASE), 1, true); + expect(familyAdds).toEqual([]); + answers.next({ [BASE]: kelon }); + expect(familyAdds).toEqual([1]); + }); + + it('follows a card with the alt arts the player owns, unlimited ones included, labelled', () => { + const alt = setup(); + alt.request([BASE]); + answers.next({ [BASE]: kelon }); + const { cards, notes } = alt.withOwnedPrints([card(BASE), card('OTHER')]); + expect(cards.map((c) => c.reference)).toEqual([BASE, ALT, 'OTHER']); + expect(notes.get(ALT)).toBe('2 possédées'); + // A print the results show already is not added twice. + expect(alt.withOwnedPrints([card(BASE), card(ALT)]).cards.map((c) => c.reference)).toEqual([BASE, ALT]); + }); + + it('gives a print the copies its family leaves, and a family card the whole limit', () => { + const alt = setup(); + alt.request([BASE]); + answers.next({ [BASE]: kelon }); + lines.set([ + { card: card(BASE), quantity: 2 }, + { card: card(ALT), quantity: 1 }, + ]); + expect(alt.maxFor(card(ALT), false)).toBe(1); + expect(alt.maxFor(card(BASE), false)).toBe(2); + expect(alt.maxFor(card(BASE), true)).toBe(3); + expect(alt.familyQuantity(card(BASE))).toBe(3); + }); +}); diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts index b709744b..085662d0 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts +++ b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts @@ -1,39 +1,61 @@ -import { Service, computed, effect, inject, untracked } from '@angular/core'; -import { rxResource, toSignal } from '@angular/core/rxjs-interop'; -import { of } from 'rxjs'; +import { Service, computed, effect, inject, signal, untracked } from '@angular/core'; +import { type Observable, filter, map, of, switchMap, take } from 'rxjs'; +import { toObservable } from '@angular/core/rxjs-interop'; +import { AuthSession } from '../../core/auth-session'; +import { basePrint, familyPrints } from '../../core/alt-art-defaults'; +import { storedFlag } from '../../core/stored-flag'; import { DeckStore } from '../../core/deck-store'; +import type { Card } from '../../core/models'; import { OwnershipApiService, familyKey, type AltArtChoice } from '../../core/ownership-api.service'; +import type { AltArtPickerData } from './alt-art-picker/alt-art-picker.overlay'; +import { openConfirm } from '../shared/confirm/confirm.overlay'; +import type { AcOverlayService } from '../../ui/overlay'; + +/** References asked to the site at once (`deck-alt-arts`, a GET with one parameter a reference). */ +const CHUNK = 60; /** - * Illustrations in the editor, as the site's deck builder: in « Global » alt-art mode the deck follows the player's - * preferred prints; otherwise each card's print can be chosen, and a print used more times than owned is flagged - * (Board Game Arena shows the base art for the missing copies). Needs the ownership service; nothing happens without. + * Illustrations in the editor (`core/alt-art-defaults.ts`): a card added from the search takes the player's default alt + * art for its copy, the brush of a card changes its prints for this deck, « Appliquer les arts par défaut » rewrites the + * deck with the defaults, and a print used more times than owned is flagged (Board Game Arena shows the base art for the + * missing copies). Needs the ownership service and a signed-in player; nothing happens without. */ @Service({ autoProvided: false }) export class EditorAltArts { private readonly deck = inject(DeckStore); private readonly ownership = inject(OwnershipApiService); - readonly global = toSignal(this.ownership.globalAltArts(), { initialValue: false }); - - /** References of the deck (hero included), sorted: the families are fetched again only when they change. */ - private readonly refs = computed( - () => { - const refs = this.deck.lines().filter((l) => l.quantity > 0).map((l) => l.card.reference); - const hero = this.deck.hero()?.reference; - return [...new Set(hero ? [...refs, hero] : refs)].sort(); - }, - { equal: (a, b) => a.join() === b.join() }, - ); - private readonly choicesRes = rxResource({ - params: () => (this.ownership.baseUrl && this.deck.editable() ? this.refs() : undefined), - stream: ({ params }) => (params.length ? this.ownership.altArtChoices(params) : of({} as Record)), + private readonly auth = inject(AuthSession); + readonly enabled = computed(() => !!this.ownership.baseUrl && this.auth.isLoggedIn()); + + /** Family of each reference asked so far (`null`: one illustration only); the prints of a family share it. */ + private readonly known = signal>(new Map()); + private readonly asked = new Set(); + /** Changes waiting for the family of their reference (a card added before its family arrived). */ + private readonly waiting = new Map void)[]>(); + + /** References of the deck (hero included). */ + private readonly deckRefs = computed(() => { + const refs = this.deck.lines().filter((l) => l.quantity > 0).map((l) => l.card.reference); + const hero = this.deck.hero()?.reference; + return hero ? [...refs, hero] : refs; + }); + /** Families of the deck's references, by reference. */ + readonly choices = computed>(() => { + const known = this.known(); + const out: Record = {}; + for (const ref of this.deckRefs()) { + const choice = known.get(ref); + if (choice) out[ref] = choice; + } + return out; }); - readonly choices = computed>(() => (this.choicesRes.hasValue() ? this.choicesRes.value() : {})); + /** Every reference of the deck has been asked to the service. */ + readonly ready = computed(() => this.deckRefs().every((r) => this.known().has(r))); + private readonly ready$ = toObservable(this.ready).pipe(filter(Boolean), take(1)); - /** Per-deck mode: why a line's print is short (owned copies under the copies of the deck), by reference. */ + /** Why a line's print is short (owned copies under the copies of the deck), by reference. */ readonly warnings = computed(() => { const out = new Map(); - if (this.global()) return out; const choices = this.choices(); for (const l of this.deck.lines()) { const owned = choices[l.card.reference]?.options.options.find((o) => o.reference === l.card.reference)?.ownedQuantity; @@ -44,28 +66,186 @@ export class EditorAltArts { return out; }); + /** Search, « Arts alternatifs » on: each card is followed by the alt arts the player owns, added as they are. */ + readonly searchPrints = storedFlag('rebuilder.search.altArtPrints', false); + + /** « Arts des jetons » and « Arts par défaut »: on an editable deck. */ + readonly canChoose = computed(() => this.enabled() && this.deck.editable()); + constructor() { - // Global mode: the deck takes the preferred prints once the families are known (and after each change). effect(() => { - const choices = this.choices(); - if (!this.global() || !Object.keys(choices).length) return; - const slots = new Map( - Object.entries(choices).map(([ref, c]) => [ref, { key: familyKey(c.family), slots: [...c.options.slots].sort((a, b) => a.slotIndex - b.slotIndex).map((s) => s.reference) }]), - ); - untracked(() => this.deck.applyAltArtSlots(slots)); + const refs = this.deckRefs(); + if (this.enabled() && this.deck.editable()) untracked(() => this.request(refs)); }); - // Global mode: a preference saved meanwhile (in the card zoom) is fetched again, for the deck to follow it. - const seen = this.ownership.altArtVersion(); + // A deck of a player switched from the « Global » mode takes their default alt arts the first time it opens here. + const checked = new Set(); effect(() => { - if (this.ownership.altArtVersion() !== seen && untracked(this.global)) untracked(() => this.choicesRes.reload()); + const id = this.deck.deckId(); + if (!id || !this.enabled() || this.deck.isGuest() || this.deck.editable() !== true || checked.has(id)) return; + checked.add(id); + untracked(() => + this.ownership + .pendingDefaults(id) + .pipe( + filter(Boolean), + switchMap(() => this.ready$), + filter(() => this.deck.deckId() === id), + ) + .subscribe(() => { + this.deck.applyAltArtDefaults(this.choices()); + this.ownership.clearPendingDefaults(id).subscribe(); + }), + ); }); } - /** « Choisir les arts des jetons »: per-deck mode with the ownership service, on an editable deck. */ - readonly canChooseTokens = computed(() => !!this.ownership.baseUrl && !this.global() && this.deck.editable()); + /** Asks the families of `refs` not asked yet (search results, deck lines). */ + request(refs: readonly string[]): void { + if (!this.enabled()) return; + const missing = [...new Set(refs)].filter((r) => !this.asked.has(r)); + for (let i = 0; i < missing.length; i += CHUNK) { + const chunk = missing.slice(i, i + CHUNK); + chunk.forEach((r) => this.asked.add(r)); + this.ownership.altArtChoices(chunk).subscribe((found) => { + this.known.update((m) => { + const next = new Map(m); + for (const ref of chunk) next.set(ref, found[ref] ?? next.get(ref) ?? null); + // Every print of a family is known with it (a print added by the brush or the search). + for (const choice of Object.values(found)) { + for (const o of choice.options.options) if (!next.get(o.reference)) next.set(o.reference, choice); + } + return next; + }); + for (const ref of chunk) { + const run = this.waiting.get(ref); + this.waiting.delete(ref); + run?.forEach((f) => f()); + } + }); + } + } - /** Per-deck mode: the prints a card of the deck can take (`null`: one illustration only, or Global mode). */ + /** The family of `reference` (`null`: one illustration, or not known yet). */ choiceFor(reference: string): AltArtChoice | null { - return this.global() ? null : this.choices()[reference] ?? null; + return this.known().get(reference) ?? null; + } + + /** References of the deck's lines in the family of `choice`. */ + members(choice: AltArtChoice): Set { + const key = familyKey(choice.family); + const known = this.known(); + return new Set( + this.deck + .lines() + .map((l) => l.card.reference) + .filter((r) => { + const c = known.get(r); + return !!c && familyKey(c.family) === key; + }), + ); + } + + /** + * Search results with « Arts alternatifs » on: after a card's plain print, the alt arts the player owns (unlimited + * ones too), unless the results show them already; `notes` labels them (« 2 possédées »). + */ + withOwnedPrints(cards: readonly Card[]): { cards: Card[]; notes: Map } { + const notes = new Map(); + const shown = new Set(cards.map((c) => c.reference)); + const out: Card[] = []; + for (const card of cards) { + out.push(card); + const choice = this.choiceFor(card.reference); + if (!choice || basePrint(choice) !== card.reference) continue; + for (const o of choice.options.options) { + if (o.reference === card.reference || shown.has(o.reference) || o.ownedQuantity === 0) continue; + shown.add(o.reference); + out.push({ ...card, reference: o.reference, imagePath: undefined }); + notes.set(o.reference, o.ownedQuantity === null ? unlimitedNote() : ownedNote(o.ownedQuantity)); + } + } + return { cards: out, notes }; + } + + /** + * Copies `card` can have: a print shares its family's limit with the other prints in the deck (never under the copies + * it has); a family card (`asFamily`, its plain print) has the whole limit. + */ + maxFor(card: Card, asFamily: boolean): number { + const max = this.deck.maxFor(card); + const choice = this.choiceFor(card.reference); + if (!choice || (asFamily && basePrint(choice) === card.reference)) return max; + const others = familyPrints(this.deck.lines(), this.members(choice)).filter((r) => r !== card.reference).length; + return Math.max(this.deck.quantityOf(card.reference), max - others); + } + + /** Copies of `card`'s family in the deck, or of `card` alone. */ + familyQuantity(card: Card): number { + const choice = this.choiceFor(card.reference); + if (!choice) return this.deck.quantityOf(card.reference); + return familyPrints(this.deck.lines(), this.members(choice)).length; + } + + /** `card` stands for its whole family: the plain print of a multi-art card. */ + isFamilyCard(card: Card): boolean { + const choice = this.choiceFor(card.reference); + return !!choice && basePrint(choice) === card.reference; + } + + /** Copies of `card` from the search: by the defaults for a family card (`setFamilyQuantity`), that print otherwise. */ + setQuantity(card: Card, quantity: number, asFamily: boolean): void { + // Asked but not answered yet: the change waits for the family, so that the copy takes its default alt art. + if (asFamily && this.asked.has(card.reference) && !this.known().has(card.reference)) { + this.waiting.set(card.reference, [...(this.waiting.get(card.reference) ?? []), () => this.setQuantity(card, quantity, asFamily)]); + return; + } + const choice = this.choiceFor(card.reference); + if (asFamily && choice && basePrint(choice) === card.reference) this.deck.setFamilyQuantity(card, choice, this.members(choice), quantity); + else this.deck.setQuantity(card, quantity); + } + + /** `card` has a brush: several illustrations, in an editable deck. */ + canIllustrate(card: Card): boolean { + return this.canChoose() && (this.choiceFor(card.reference)?.options.options.length ?? 0) > 1; } + + /** The brush of `card` (`canIllustrate`), `null` otherwise. */ + pickerFor(card: Card): AltArtPickerData | null { + const choice = this.choiceFor(card.reference); + if (!choice || !this.canIllustrate(card)) return null; + return { + choice, + prints: () => familyPrints(this.deck.lines(), this.members(choice)), + set: (prints) => this.deck.setFamilyPrints(this.members(choice), card, prints), + }; + } + + /** « Appliquer les arts par défaut », once the deck's families are known: `false` when the deck already has them. */ + applyDefaults(): Observable { + return this.ready$.pipe(map(() => this.deck.applyAltArtDefaults(this.choices()))); + } +} + +const unlimitedNote = () => $localize`:@@search.altArts.unlimited:Illimitée`; +const ownedNote = (n: number) => (n > 1 ? $localize`:@@search.altArts.ownedMany:${n}:n: possédées` : $localize`:@@search.altArts.ownedOne:${n}:n: possédée`); + +/** + * « Arts par défaut » (deck bar, « Mon deck »): after a confirmation, the deck takes the player's default alt arts. + * Emits the message to show, `null` when cancelled. + */ +export function confirmAltArtDefaults(overlay: AcOverlayService, altArts: EditorAltArts): Observable { + return openConfirm(overlay, { + title: $localize`:@@editor.altArtDefaults.title:Appliquer vos arts par défaut ?`, + message: $localize`:@@editor.altArtDefaults.message:Chaque carte du deck, et le héros, prend vos arts alternatifs par défaut. Les illustrations choisies pour ce deck sont remplacées.`, + confirmLabel: $localize`:@@editor.altArtDefaults.confirm:Appliquer`, + icon: 'wand-sparkles', + }).pipe( + switchMap((ok) => (ok ? altArts.applyDefaults() : of(null))), + map((applied) => { + if (applied === null) return null; + return applied + ? $localize`:@@editor.altArtDefaults.applied:Arts par défaut appliqués.` + : $localize`:@@editor.altArtDefaults.unchanged:Le deck a déjà vos arts par défaut.`; + }), + ); } diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html index 397a71c0..e2320b35 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html @@ -138,8 +138,9 @@ @if (deck.editable()) { } - @if (altArts.canChooseTokens()) { + @if (altArts.canChoose()) { + }
@@ -178,7 +179,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 7c718525..956a336d 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 @@ -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'; @@ -333,6 +333,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..65c8f0a4 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,88 @@ -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'; -/** « 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 printLabel = (n: number) => $localize`:@@altArt.tile:Illustration ${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.`; +const saveFailed = () => $localize`:@@altArt.saveError:Impossible d’enregistrer votre choix.`; + 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/card-search/card-search.html b/plugins/rebuilder/app/src/app/features/search/card-search/card-search.html index 9893d3fe..10257f00 100644 --- a/plugins/rebuilder/app/src/app/features/search/card-search/card-search.html +++ b/plugins/rebuilder/app/src/app/features/search/card-search/card-search.html @@ -70,6 +70,9 @@
{{ totalLabel() }} + @if (printsSwitch() && altArts; as a) { + + } @if (sortable()) { } @@ -96,6 +99,9 @@ } + @if (printsSwitch() && altArts; as a) { + + } @if (sortable()) { } diff --git a/plugins/rebuilder/app/src/app/features/search/card-search/card-search.ts b/plugins/rebuilder/app/src/app/features/search/card-search/card-search.ts index 199b8fb4..cd0dad54 100644 --- a/plugins/rebuilder/app/src/app/features/search/card-search/card-search.ts +++ b/plugins/rebuilder/app/src/app/features/search/card-search/card-search.ts @@ -10,7 +10,7 @@ import { AcButton, AcIconButton } from '../../../ui/buttons'; import { AcCount, AcFilterBar } from '../../../ui/chips'; import { AcDrawerHandle, AcDrawerState, AcDrawerTab } from '../../../ui/containers'; import { storedFlag } from '../../../core/stored-flag'; -import { AcInput, AcSegmented, AcSelect } from '../../../ui/fields'; +import { AcInput, AcSegmented, AcSelect, AcSwitch } from '../../../ui/fields'; import { AcBreakpointService } from '../../../ui/layout.services'; import { AcTabs } from '../../../ui/nav'; import { AcOverlayService } from '../../../ui/overlay'; @@ -19,6 +19,7 @@ import { openEffectEditor } from '../effect-editor/effect-editor.overlay'; import { FiltersPanel } from '../filters-panel/filters-panel'; import { openFiltersSheet } from '../filters-sheet/filters-sheet.overlay'; import { SearchResults } from '../search-results/search-results'; +import { EditorAltArts } from '../../editor/editor-alt-arts'; export interface CardSourceTab { id: CardSource; @@ -48,7 +49,7 @@ const TEXT_FILTERS = ['q', 'mainCost', 'recallCost', 'forestPower', 'mountainPow */ @Component({ selector: 'app-card-search', - imports: [AcTabs, AcSegmented, AcSelect, AcInput, AcIconButton, AcButton, AcCount, AcFilterBar, AcDrawerHandle, AcDrawerTab, FiltersPanel, SearchResults], + imports: [AcTabs, AcSegmented, AcSelect, AcInput, AcIconButton, AcButton, AcCount, AcFilterBar, AcDrawerHandle, AcDrawerTab, FiltersPanel, SearchResults, AcSwitch], host: { '[class.compact]': 'bp.compact()' }, templateUrl: './card-search.html', styleUrl: './card-search.scss', @@ -60,6 +61,8 @@ export class CardSearch { private readonly uniquesApi = inject(UniquesApiService); protected readonly bp = inject(AcBreakpointService); protected readonly search = inject(CardSearchStore); + /** Editor: « Arts alternatifs » (the alt arts the player owns, after their card). */ + protected readonly altArts = inject(EditorAltArts, { optional: true }); private readonly injector = inject(Injector); private readonly el = inject>(ElementRef).nativeElement; @@ -72,6 +75,7 @@ export class CardSearch { readonly ready = input(true); /** Card browser: faction filter, no quantity controls on the cards. */ readonly browse = input(false); + protected readonly printsSwitch = computed(() => !this.browse() && !!this.altArts?.enabled()); protected readonly source = toSignal( this.route.queryParamMap.pipe(map((q) => (q.get('source') as CardSource) || 'all')), 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..93aee270 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 @@ -14,20 +14,21 @@
} @else { @if (layout() === 'grid') { - + @for (c of grid.slice(); track c.reference) { } @else {
- @for (c of search.cards(); track c.reference) { + @for (c of cards(); track c.reference) { }
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..ec22c751 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,11 +27,45 @@ export class SearchResults { protected readonly auth = inject(AuthSession); private readonly overlay = inject(AcOverlayService); protected readonly favorites = inject(FavoritesService); + /** Editor: illustrations (default alt arts, « Arts alternatifs »). */ + 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 })); + /** « Arts alternatifs » on: cards added as shown; off: a card's plain print stands for all its prints. */ + private readonly prints = computed(() => !this.browse() && !!this.altArts?.enabled() && this.altArts.searchPrints()); + /** The results, with the owned alt arts after their card when « Arts alternatifs » is on. */ + private readonly shown = computed(() => (this.prints() && this.altArts ? this.altArts.withOwnedPrints(this.search.cards()) : { cards: this.search.cards(), notes: new Map() })); + protected readonly cards = computed(() => this.shown().cards); + + constructor() { + // The families of the results: the copies a card counts and the alt arts the player owns. + effect(() => { + const cards = this.search.cards(); + if (!this.browse() && this.altArts?.enabled()) untracked(() => this.altArts?.request(cards.map((c) => c.reference))); + }); + } + + protected noteOf(card: Card): string | null { + return this.shown().notes.get(card.reference) ?? null; + } + + /** Copies in the deck: the whole family for a plain print with « Arts alternatifs » off, that print otherwise. */ + protected quantityOf(card: Card): number { + if (this.altArts && !this.prints() && 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, !this.prints()) : this.deck.maxFor(card); + } + + protected setQuantity(card: Card, quantity: number): void { + if (this.altArts) this.altArts.setQuantity(card, quantity, !this.prints()); + 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 { @@ -42,7 +77,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..e40c68a9 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 @@ -14,6 +14,11 @@ @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 0dd02ea4..1fd6286f 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 @@ -74,6 +74,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..84b24ef1 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 @@ -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', @@ -36,6 +37,9 @@ 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)); @@ -49,4 +53,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..bbad3ab1 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 @@ -6,14 +6,22 @@ } @if (zoomable()) { - } @else { -
+
} +@if (note(); as n) { + {{ n }} +} +@if (illustrations()) { + +} @if (favorite() !== null) { } @else { -
+
} -@if (note(); as n) { - {{ n }} -} @if (illustrations()) { +
+ @if (s.chosen) { + + } @else { +
+ + Par défaut +
+ } +
}

Illustrations disponibles

-
    +
      @for (p of pool(); track p.reference) { -
    • - @@ -24,6 +38,6 @@

      Illustrations disp

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 index f5f953ff..c9855191 100644 --- 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 @@ -48,12 +48,30 @@ ul { font: inherit; color: inherit; } -.slot { +/* A copy: its illustration, a drop target for the illustrations below. */ +.slot-drop { + position: relative; width: 100%; border-radius: var(--ac-radius-md); - outline: 2px solid transparent; + outline: 2px dashed transparent; outline-offset: 2px; } +.slot-drop.cdk-drop-list-dragging { + outline-color: var(--ac-color-primary); +} +/* The dragged illustration's place holder: out of the copy's layout. */ +.slot-drop .cdk-drag-placeholder { + position: absolute; + inset: 0; + opacity: 0; + pointer-events: none; +} +.slot { + position: relative; + display: block; + width: 100%; + border-radius: var(--ac-radius-md); +} .slot img { display: block; width: 100%; @@ -61,8 +79,37 @@ ul { object-fit: cover; border-radius: var(--ac-radius-md); } -.slot.active { - outline-color: var(--ac-color-primary); +/* A copy left to its default alt art: faded. */ +.slot.default img { + opacity: 0.4; +} +.default-tag { + position: absolute; + left: 50%; + bottom: var(--ac-space-2); + transform: translateX(-50%); + padding: var(--ac-space-1) var(--ac-space-2); + border-radius: var(--ac-radius-pill); + background: var(--ac-color-surface); + box-shadow: var(--ac-shadow-e3); + font-size: var(--ac-font-size-caption); + font-weight: 700; + white-space: nowrap; + color: var(--ac-color-text-muted); +} +/* A chosen copy: touched, it goes back to its default alt art. */ +.free { + position: absolute; + top: var(--ac-space-1); + right: var(--ac-space-1); + display: grid; + place-items: center; + width: var(--ac-control-sm); + height: var(--ac-control-sm); + border-radius: 50%; + background: var(--ac-color-surface); + box-shadow: var(--ac-shadow-e3); + color: var(--ac-color-text); } .pool-title { margin: 0; @@ -106,6 +153,13 @@ ul { .print.off img { opacity: 0.4; } +.pool .cdk-drag-preview .print, +.pool .cdk-drag-placeholder .print { + box-shadow: var(--ac-shadow-e2); +} +.pool .cdk-drag-placeholder { + opacity: 0.5; +} .note { font-size: var(--ac-font-size-caption); color: var(--ac-color-text-muted); 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 index 8dec79a2..c995d820 100644 --- 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 @@ -1,9 +1,11 @@ +import { CdkDrag, CdkDropList, CdkDropListGroup, type CdkDragDrop } from '@angular/cdk/drag-drop'; import { Component, computed, inject, signal } from '@angular/core'; import { environment } from '../../../../environments/environment'; -import { counts, defaultPrints } from '../../../core/alt-art-defaults'; +import { slotChoices, slotDefaults } 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 { AcIcon } from '../../../ui/icon'; import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; export interface AltArtPickerData { @@ -15,13 +17,14 @@ export interface AltArtPickerData { } /** - * The brush of a card: the illustration of each of its copies in this deck. A touched illustration goes on the selected - * copy, then the next copy is selected; an illustration whose owned copies are all placed waits. « Arts par défaut » puts - * the player's default alt arts back. Each change is saved with the deck. + * The brush of a card: the illustration of each of its copies in this deck. A copy left free shows, faded, its default + * alt art (the player's slot for that copy, within the copies owned), which the deck takes. A touched illustration goes + * on the first free copy; one dragged onto a copy goes on that copy; a touched copy is free again. An illustration whose + * owned copies are all chosen waits. Each change is saved with the deck. */ @Component({ selector: 'app-alt-art-picker', - imports: [AcButton], + imports: [AcButton, AcIcon, CdkDropListGroup, CdkDropList, CdkDrag], host: { class: 'ac-overlay-content' }, templateUrl: './alt-art-picker.overlay.html', styleUrl: './alt-art-picker.overlay.scss', @@ -29,74 +32,84 @@ export interface AltArtPickerData { export class AltArtPickerOverlay { protected readonly ref = inject>(AcOverlayRef); private readonly data = this.ref.data!; - /** - * The copies, the ones on a default alt art in the order of the defaults (1st copy on the 1st default…) when the - * window opens; then each copy keeps its place while the illustrations change. - */ - protected readonly copies = signal(ordered(this.data.choice, this.data.prints())); - /** The selected copy: the next illustration touched goes on it. */ - protected readonly active = signal(0); - protected readonly slots = computed(() => this.copies().map((reference, i) => ({ reference, src: cardImageUrl(reference), label: copyLabel(i + 1) }))); + /** Each copy's illustration chosen in this deck, `null` for a copy left to its default alt art. */ + protected readonly chosen = signal(slotChoices(this.data.choice, this.data.prints())); + /** Each copy's illustration in the deck. */ + private readonly prints = computed(() => slotDefaults(this.data.choice, this.chosen())); + protected readonly slots = computed(() => { + const chosen = this.chosen(); + return this.prints().map((reference, i) => ({ + src: cardImageUrl(reference), + label: copyLabel(i + 1), + chosen: chosen[i] !== null, + freeLabel: freeLabel(i + 1), + })); + }); + protected readonly full = computed(() => !this.chosen().includes(null)); protected readonly pool = computed(() => { - const copies = this.copies(); - const active = this.active(); + const prints = this.prints(); + const chosen = this.chosen(); return this.data.choice.options.options.map((o, i) => { - const placed = copies.filter((c) => c === o.reference).length; - // Taken by the other copies: the selected copy gives its own print back when it changes. - const elsewhere = copies.filter((c, j) => c === o.reference && j !== active).length; + const placed = prints.filter((p) => p === o.reference).length; const owned = o.ownedQuantity; return { reference: o.reference, src: cardImageUrl(o.reference), label: printLabel(i + 1), - off: owned !== null && elsewhere >= owned, - unowned: owned === 0, + off: this.taken(o.reference, chosen), note: owned === null ? unlimited() : owned === 0 ? notOwned() : placedLabel(placed, owned), }; }); }); - protected readonly isDefault = computed(() => this.copies().join() === defaultPrints(this.data.choice, this.copies().length).join()); + protected readonly allDefault = computed(() => this.chosen().every((c) => c === null)); /** The site's « Arts alternatifs par défaut » page (plugin ownership). */ protected readonly settingsUrl = `${environment.siteUrl.replace(/\/$/, '')}/pages/ownership-alt-arts`; + /** A long press starts a drag on touch screens, so that a swipe still scrolls the illustrations. */ + protected readonly dragDelay = { touch: 300, mouse: 0 }; + /** Nothing is dropped on the illustrations. */ + protected readonly noDrop = () => false; + + /** A touched illustration: on the first free copy. */ + protected place(reference: string): void { + const index = this.chosen().indexOf(null); + if (index !== -1) this.choose(index, reference); + } - protected select(index: number): void { - this.active.set(index); + /** An illustration dropped on a copy. */ + protected drop(event: CdkDragDrop): void { + if (event.container !== event.previousContainer) this.choose(event.container.data, event.item.data); } - protected place(reference: string, off: boolean): void { - const copies = this.copies(); - if (off || !copies.length) return; - const active = this.active(); - const next = copies.map((c, i) => (i === active ? reference : c)); - this.copies.set(next); - this.active.set((active + 1) % next.length); - this.data.set(next); + /** A touched copy: back to its default alt art. */ + protected free(index: number): void { + this.update(this.chosen().map((c, i) => (i === index ? null : c))); } protected resetToDefaults(): void { - const next = defaultPrints(this.data.choice, this.copies().length); - this.copies.set(next); - this.active.set(0); - this.data.set(next); + this.update(this.chosen().map(() => null)); + } + + private choose(index: number, reference: string): void { + const chosen = this.chosen(); + if (chosen[index] === reference || this.taken(reference, chosen.map((c, i) => (i === index ? null : c)))) return; + this.update(chosen.map((c, i) => (i === index ? reference : c))); + } + + /** Every owned copy of `reference` is chosen (or the player owns none). */ + private taken(reference: string, chosen: readonly (string | null)[]): boolean { + const owned = this.data.choice.options.options.find((o) => o.reference === reference)?.ownedQuantity ?? null; + return owned !== null && chosen.filter((c) => c === reference).length >= owned; } -} -/** `prints` with the copies on a default alt art first, in the order of the defaults, then the others. */ -function ordered(choice: AltArtChoice, prints: readonly string[]): string[] { - const left = counts(prints); - const out: string[] = []; - for (const d of defaultPrints(choice, prints.length)) { - const n = left.get(d) ?? 0; - if (!n) continue; - out.push(d); - left.set(d, n - 1); + private update(chosen: (string | null)[]): void { + this.chosen.set(chosen); + this.data.set(this.prints()); } - for (const [print, n] of left) for (let i = 0; i < n; i++) out.push(print); - return out; } const copyLabel = (n: number) => $localize`:@@altArt.marker:Exemplaire ${n}:n:`; const printLabel = (n: number) => $localize`:@@altArt.tile:Illustration ${n}:n:`; +const freeLabel = (n: number) => $localize`:@@editor.altArtPicker.free:Exemplaire ${n}:n:, illustration choisie : rendre l’art par défaut`; const unlimited = () => $localize`:@@editor.altArtPicker.unlimited:Illimitée`; const notOwned = () => $localize`:@@editor.altArtPicker.notOwned:Non possédée`; const placedLabel = (placed: number, owned: number) => 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 84b24ef1..4f4f1a5c 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({ diff --git a/plugins/rebuilder/app/src/locale/messages.en.json b/plugins/rebuilder/app/src/locale/messages.en.json index 4889259b..77b65084 100644 --- a/plugins/rebuilder/app/src/locale/messages.en.json +++ b/plugins/rebuilder/app/src/locale/messages.en.json @@ -776,7 +776,10 @@ "editor.overview.openDeck": "Open the Deck tab", "editor.changeHeroOf": "Change hero ({$name})", "editor.overviewOf": "Deck summary {$name}", - "editor.altArtPicker.hint": "Touch an illustration to put it on the selected copy.", + "editor.altArtPicker.hint": "Touch an illustration to put it on the next copy, or drag it onto the copy you want. Faded: the default alt art.", + "editor.altArtPicker.hintFull": "Every copy has its illustration. Touch a copy to give it back its default alt art, or drag an illustration onto it.", + "editor.altArtPicker.default": "Default", + "editor.altArtPicker.free": "Copy {$n}, illustration chosen: back to the default alt art", "editor.altArtPicker.copies": "Copies", "editor.altArtPicker.available": "Available illustrations", "editor.altArtPicker.settings": "Edit my default alt arts", diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index c0b74a33..85814b18 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -1163,16 +1163,50 @@ test.describe('ReBuilder in the shell · deck page', () => { await page.goto(`${EDITOR(id)}&view=apercu`); await page.getByRole('button', { name: 'Choisir les illustrations de Élémentaire de Kélon' }).click(); const dialog = page.getByRole('dialog', { name: 'Élémentaire de Kélon' }); - await expect(dialog.getByRole('button', { name: /^Exemplaire \d$/ })).toHaveCount(3); - // The 2nd copy takes the alt art (one owned): the deck is saved with it. - const saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 1 && c.get(kelon.base) === 2); - await dialog.getByRole('button', { name: 'Exemplaire 2' }).click(); + const copies = dialog.getByRole('list', { name: 'Exemplaires' }).getByRole('listitem'); + const chosen = (n: number) => dialog.getByRole('button', { name: `Exemplaire ${n}, illustration choisie : rendre l’art par défaut` }); + // Three copies on their default alt art (the plain print here), faded. + await expect(copies).toHaveCount(3); + await expect(copies.getByText('Par défaut', { exact: true })).toHaveCount(3); + // A touched illustration goes on the first free copy: the deck is saved with it. + let saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 1 && c.get(kelon.base) === 2); await dialog.getByRole('button', { name: /^Illustration 2,/ }).click(); await saved; - // The 3rd copy is selected next: the only alt art owned is on the 2nd one already. - await expect(dialog.getByRole('button', { name: 'Exemplaire 3' })).toHaveAttribute('aria-pressed', 'true'); + await expect(chosen(1)).toBeVisible(); + await expect(copies.getByText('Par défaut', { exact: true })).toHaveCount(2); + // The only alt art owned is placed: it waits. await expect(dialog.getByRole('button', { name: /^Illustration 2,/ })).toHaveAttribute('aria-disabled', 'true'); await evidence(page, testInfo, '30-alt-art-brush'); + // A touched copy goes back to its default alt art. + saved = savedCards(page, id, (c) => !c.has(kelon.alt()) && c.get(kelon.base) === 3); + await chosen(1).click(); + await saved; + await expect(copies.getByText('Par défaut', { exact: true })).toHaveCount(3); + // An illustration dragged onto a copy goes on that copy. + saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 1 && c.get(kelon.base) === 2); + const from = (await dialog.getByRole('button', { name: /^Illustration 2,/ }).boundingBox())!; + const to = (await copies.nth(2).locator('.slot-drop').boundingBox())!; + await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); + await page.mouse.down(); + await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 }); + await page.mouse.up(); + await saved; + await expect(chosen(3)).toBeVisible(); + await expect(chosen(1)).toHaveCount(0); + }); + + test('takes a card brought to 0 off the deck board', async ({ page, compact }) => { + test.skip(compact, 'the deck board is from 768 px'); + await login(page, 'alice', `${DECKS}?lang=fr`); + const id = await createServerDeck(page, `E2E board ${Date.now()}`, ['ALT_CORE_B_AX_04_C']); + await page.goto(`${EDITOR(id)}&view=apercu`); + const piles = page.locator('app-deck-board ac-card-pile'); + const kelon = piles.filter({ has: page.getByRole('group', { name: 'Exemplaires de Élémentaire de Kélon dans le deck' }) }); + await expect(piles).toHaveCount(4); + await kelon.hover(); + await kelon.getByRole('button', { name: 'Retirer un exemplaire de Élémentaire de Kélon' }).click(); + await expect(piles).toHaveCount(3); + await expect(kelon).toHaveCount(0); }); test('adds a card from the search with the default alt art of its copy', { tag: '@mobile' }, async ({ page }, testInfo) => { diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 00000000..5fca3f84 --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file From 94d5872692890c45ff0ad85d6ebf56fdd0ea19bf Mon Sep 17 00:00:00 2001 From: Yutsa Date: Thu, 8 Oct 2026 16:48:04 +0200 Subject: [PATCH 04/38] Remove a stray Playwright run file Co-Authored-By: Claude Opus 5.5 --- test-results/.last-run.json | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 test-results/.last-run.json diff --git a/test-results/.last-run.json b/test-results/.last-run.json deleted file mode 100644 index 5fca3f84..00000000 --- a/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "failed", - "failedTests": [] -} \ No newline at end of file From fd053b073071462cd936725d306d6fafefe1db37 Mon Sep 17 00:00:00 2001 From: Yutsa Date: Thu, 8 Oct 2026 17:01:36 +0200 Subject: [PATCH 05/38] =?UTF-8?q?Re:Builder:=20=C2=AB=20Regrouper=20les=20?= =?UTF-8?q?illustrations=20=C2=BB=20puts=20a=20card's=20prints=20on=20one?= =?UTF-8?q?=20pile=20of=20the=20deck=20board,=20copy=201=20in=20front?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On by default, remembered in the browser. The brush's copy 1 is the deck's own choice: the prints the defaults do not explain come first. Co-Authored-By: Claude Opus 5.5 --- plugins/rebuilder/app/design/COMPONENTS.md | 2 +- .../app/src/app/core/alt-art-defaults.spec.ts | 7 ++- .../app/src/app/core/alt-art-defaults.ts | 27 ++++---- .../features/deck/deck-board/deck-board.html | 8 ++- .../features/deck/deck-board/deck-board.ts | 63 ++++++++++++++++--- .../app/features/editor/editor-alt-arts.ts | 24 ++++++- .../editor/editor-page/editor.page.html | 3 + .../editor/editor-page/editor.page.ts | 3 +- .../app/ui/metier/card-pile/card-pile.html | 10 +-- .../src/app/ui/metier/card-pile/card-pile.ts | 4 ++ .../rebuilder/app/src/locale/messages.en.json | 1 + plugins/rebuilder/e2e/rebuilder.spec.ts | 32 ++++++++++ 12 files changed, 151 insertions(+), 33 deletions(-) diff --git a/plugins/rebuilder/app/design/COMPONENTS.md b/plugins/rebuilder/app/design/COMPONENTS.md index 7db7f368..e1cbd8a0 100644 --- a/plugins/rebuilder/app/design/COMPONENTS.md +++ b/plugins/rebuilder/app/design/COMPONENTS.md @@ -116,7 +116,7 @@ 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) ; `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 ») ; `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-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/src/app/core/alt-art-defaults.spec.ts b/plugins/rebuilder/app/src/app/core/alt-art-defaults.spec.ts index 4c4acd95..2d38dd32 100644 --- a/plugins/rebuilder/app/src/app/core/alt-art-defaults.spec.ts +++ b/plugins/rebuilder/app/src/app/core/alt-art-defaults.spec.ts @@ -56,16 +56,17 @@ describe('slotDefaults', () => { 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', () => { + 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([null, BASE, 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]]) { + 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); } }); diff --git a/plugins/rebuilder/app/src/app/core/alt-art-defaults.ts b/plugins/rebuilder/app/src/app/core/alt-art-defaults.ts index 09e62dbb..984a9aa0 100644 --- a/plugins/rebuilder/app/src/app/core/alt-art-defaults.ts +++ b/plugins/rebuilder/app/src/app/core/alt-art-defaults.ts @@ -53,25 +53,28 @@ export function slotDefaults(choice: AltArtChoice, chosen: readonly (string | nu /** * 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: a copy on its slot's default is free, the other prints go on the other copies. + * `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); - const defaults = defaultPrints(choice, prints.length); - const free = defaults.map((d) => { + for (const d of defaultPrints(choice, prints.length)) { const n = left.get(d) ?? 0; if (n) left.set(d, n - 1); - return n > 0; - }); - const rest = [...left].flatMap(([print, n]) => Array(n).fill(print)); - const assigned = defaults.map((d, i) => (free[i] ? d : rest.shift()!)); - const chosen = assigned.map((a, i) => (free[i] ? null : a)); - // A chosen copy can take an owned print from a free copy after it: that copy is chosen too. + } + 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); - const moved = shown.findIndex((s, i) => s !== assigned[i]); - if (moved === -1) return chosen; - chosen[moved] = assigned[moved]; + 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; } } 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 97f3d28a..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,13 +12,15 @@

{{ 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); !this.readonly() && this.deck.editable()); + 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 { 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.deck.lines().filter((l) => l.quantity > 0))); + 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; @@ -60,12 +97,24 @@ export class DeckBoard { }); } + /** 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 { - this.deck.setQuantity(card, quantity); + 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). */ @@ -84,7 +133,7 @@ export class DeckBoard { const editable = this.editable(); openCardZoom(this.overlay, { card, - quantity: editable ? { value: this.quantityOf(card), max: this.deck.maxFor(card), change: (n) => this.setQuantity(card, n) } : 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/editor/editor-alt-arts.ts b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts index 9f141b7f..e2bff2df 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts +++ b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts @@ -2,8 +2,9 @@ import { Service, computed, effect, inject, signal, untracked } from '@angular/c import { type Observable, filter, map, of, switchMap, take } from 'rxjs'; import { toObservable } from '@angular/core/rxjs-interop'; import { AuthSession } from '../../core/auth-session'; -import { basePrint, familyPrints } from '../../core/alt-art-defaults'; +import { basePrint, familyPrints, slotChoices, slotDefaults } from '../../core/alt-art-defaults'; import { DeckStore } from '../../core/deck-store'; +import { storedFlag } from '../../core/stored-flag'; import type { Card } from '../../core/models'; import { OwnershipApiService, familyKey, type AltArtChoice } from '../../core/ownership-api.service'; import type { AltArtPickerData } from './alt-art-picker/alt-art-picker.overlay'; @@ -65,6 +66,9 @@ export class EditorAltArts { return out; }); + /** The deck board (« Aperçu »): the prints of a card on one pile, copy 1 in front. Remembered in this browser. */ + readonly stackPrints = storedFlag('rebuilder.board.stackPrints', true); + /** « Arts des jetons » and « Arts par défaut »: on an editable deck. */ readonly canChoose = computed(() => this.enabled() && this.deck.editable()); @@ -160,6 +164,23 @@ export class EditorAltArts { return familyPrints(this.deck.lines(), this.members(choice)).length; } + /** + * The copies of `card`'s family in the deck, one print a copy, in the brush's order (copy 1 first: the copies on + * their default alt art on their slot); `null` when its family is not known. + */ + copyPrints(card: Card): string[] | null { + const choice = this.choiceFor(card.reference); + if (!choice) return null; + return slotDefaults(choice, slotChoices(choice, familyPrints(this.deck.lines(), this.members(choice)))); + } + + /** Copies of `card`'s whole family (a pile of its prints): an added copy takes its default alt art. */ + setFamilyQuantity(card: Card, quantity: number): void { + const choice = this.choiceFor(card.reference); + if (choice) this.deck.setFamilyQuantity(card, choice, this.members(choice), quantity); + else this.deck.setQuantity(card, quantity); + } + /** `card` stands for its whole family: the plain print of a multi-art card. */ isFamilyCard(card: Card): boolean { const choice = this.choiceFor(card.reference); @@ -220,3 +241,4 @@ export function confirmAltArtDefaults(overlay: AcOverlayService, altArts: Editor }), ); } + diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html index e2320b35..23dd1d73 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html @@ -135,6 +135,9 @@ Survolez une pile pour changer le nombre d’exemplaires. } + @if (effectiveView() === 'apercu' && !bp.compact() && altArts.canChoose()) { + + } @if (deck.editable()) { } 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 956a336d..cbe673a6 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'; @@ -54,6 +54,7 @@ export type EditorAction = 'share'; selector: 'app-editor-page', providers: [CardSearchStore, EditorAltArts], imports: [ + AcSwitch, AcToast, AcDrawerHandle, AcDrawerTab, 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 e40c68a9..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,15 +1,15 @@ - + @if (unique()) { } @else { - + } @if (issues().length && quantity() > 0) { 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 4f4f1a5c..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 @@ -30,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). */ @@ -45,6 +47,8 @@ export class AcCardPile { 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 diff --git a/plugins/rebuilder/app/src/locale/messages.en.json b/plugins/rebuilder/app/src/locale/messages.en.json index 77b65084..e64f8650 100644 --- a/plugins/rebuilder/app/src/locale/messages.en.json +++ b/plugins/rebuilder/app/src/locale/messages.en.json @@ -755,6 +755,7 @@ "deck.page.tabPreview": "Preview", "editor.imageOpen": "Open the image in a new tab", "editor.boardHint": "Hover a pile to change its number of copies.", + "editor.stackPrints": "Group illustrations", "editor.tokenArts.short": "Token illustrations", "editor.moreActions": "More actions", "deck.bar.rarities": "Rarities", diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index 85814b18..227d500d 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -1209,6 +1209,38 @@ test.describe('ReBuilder in the shell · deck page', () => { await expect(kelon).toHaveCount(0); }); + test('puts a card’s illustrations on one pile of the deck board, copy 1 in front, or on a pile each', async ({ page, compact }, testInfo) => { + test.skip(compact, 'the deck board is from 768 px'); + const kelon = await routeAltArts(page, 1, [0, 0, 0]); + await login(page, 'alice', `${DECKS}?lang=fr`); + const id = await createServerDeck(page, `E2E stack ${Date.now()}`, ['ALT_CORE_B_AX_04_C'], 3); + await page.goto(`${EDITOR(id)}&view=apercu`); + const piles = page.locator('app-deck-board ac-card-pile').filter({ has: page.getByRole('group', { name: 'Exemplaires de Élémentaire de Kélon dans le deck' }) }); + // The alt art on copy 1 (the brush): the deck has two prints of the card. + await page.getByRole('button', { name: 'Choisir les illustrations de Élémentaire de Kélon' }).click(); + const saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 1 && c.get(kelon.base) === 2); + await page.getByRole('dialog').getByRole('button', { name: /^Illustration 2,/ }).click(); + await saved; + await page.keyboard.press('Escape'); + // One pile of 3, copy 1 in front. + const stack = page.getByRole('switch', { name: 'Regrouper les illustrations' }); + await expect(stack).toBeChecked(); + await expect(piles).toHaveCount(1); + await expect(piles.locator('.qty')).toHaveText('×3'); + await expect(piles.locator('.front img')).toHaveAttribute('src', new RegExp(kelon.alt())); + await evidence(page, testInfo, '32-alt-arts-one-pile'); + // Its stepper adds a copy of the card: back to the deck limit, the pile stays. + await piles.hover(); + await piles.getByRole('button', { name: 'Retirer un exemplaire de Élémentaire de Kélon' }).click(); + await expect(piles.locator('.qty')).toHaveText('×2'); + await expect(piles.locator('.front img')).toHaveAttribute('src', new RegExp(kelon.alt())); + // Off: a pile each print. + await stack.uncheck(); + await expect(piles).toHaveCount(2); + await stack.check(); + await expect(piles).toHaveCount(1); + }); + test('adds a card from the search with the default alt art of its copy', { tag: '@mobile' }, async ({ page }, testInfo) => { const kelon = await routeAltArts(page, 1, [1, 1, 0]); await login(page, 'alice', `${DECKS}?lang=fr`); From f2a654ac12d5637afd13f7a631afb093c7796b31 Mon Sep 17 00:00:00 2001 From: Yutsa Date: Thu, 8 Oct 2026 17:13:01 +0200 Subject: [PATCH 06/38] Re:Builder brush: the default alt arts show as chosen; a touched copy is freed for the next illustration Co-Authored-By: Claude Opus 5.5 --- .../alt-art-picker.overlay.html | 13 ++++------ .../alt-art-picker.overlay.scss | 24 +++++++----------- .../alt-art-picker/alt-art-picker.overlay.ts | 24 +++++++++--------- .../rebuilder/app/src/locale/messages.en.json | 8 +++--- plugins/rebuilder/e2e/rebuilder.spec.ts | 25 +++++++++++-------- 5 files changed, 44 insertions(+), 50 deletions(-) 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 index 7d149594..2f2ddef3 100644 --- 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 @@ -1,24 +1,21 @@
@if (full()) { -

Chaque exemplaire a son illustration. Touchez un exemplaire pour lui rendre son art par défaut, ou glissez une illustration dessus.

+

Touchez un exemplaire pour le libérer, ou glissez une illustration dessus.

} @else { -

Touchez une illustration pour la poser sur le prochain exemplaire, ou glissez-la sur l’exemplaire voulu. En transparence : l’art par défaut.

+

Touchez une illustration pour la poser sur l’exemplaire libre, ou glissez-la sur l’exemplaire voulu.

}
    @for (s of slots(); track $index) {
  • {{ s.label }}
    - @if (s.chosen) { + @if (s.free) { +
    Libre
    + } @else { - } @else { -
    - - Par défaut -
    }
  • 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 index c9855191..89354b76 100644 --- 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 @@ -79,25 +79,19 @@ ul { object-fit: cover; border-radius: var(--ac-radius-md); } -/* A copy left to its default alt art: faded. */ -.slot.default img { - opacity: 0.4; -} -.default-tag { - position: absolute; - left: 50%; - bottom: var(--ac-space-2); - transform: translateX(-50%); - padding: var(--ac-space-1) var(--ac-space-2); - border-radius: var(--ac-radius-pill); +/* A freed copy: waiting for an illustration. */ +.slot.empty { + display: grid; + place-items: center; + aspect-ratio: 5 / 7; + box-sizing: border-box; + border: 2px dashed var(--ac-color-border); background: var(--ac-color-surface); - box-shadow: var(--ac-shadow-e3); - font-size: var(--ac-font-size-caption); + font-size: var(--ac-font-size-small); font-weight: 700; - white-space: nowrap; color: var(--ac-color-text-muted); } -/* A chosen copy: touched, it goes back to its default alt art. */ +/* A copy's ✕: touched, the copy is free. */ .free { position: absolute; top: var(--ac-space-1); 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 index c995d820..521e353f 100644 --- 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 @@ -1,7 +1,7 @@ import { CdkDrag, CdkDropList, CdkDropListGroup, type CdkDragDrop } from '@angular/cdk/drag-drop'; import { Component, computed, inject, signal } from '@angular/core'; import { environment } from '../../../../environments/environment'; -import { slotChoices, slotDefaults } from '../../../core/alt-art-defaults'; +import { defaultPrints, slotChoices, slotDefaults } from '../../../core/alt-art-defaults'; import { cardImageUrl } from '../../../core/card-art'; import type { AltArtChoice } from '../../../core/ownership-api.service'; import { AcButton } from '../../../ui/buttons'; @@ -17,10 +17,10 @@ export interface AltArtPickerData { } /** - * The brush of a card: the illustration of each of its copies in this deck. A copy left free shows, faded, its default - * alt art (the player's slot for that copy, within the copies owned), which the deck takes. A touched illustration goes - * on the first free copy; one dragged onto a copy goes on that copy; a touched copy is free again. An illustration whose - * owned copies are all chosen waits. Each change is saved with the deck. + * The brush of a card: the illustration of each of its copies in this deck, the default alt arts as chosen as the + * others. A touched copy is freed (it keeps its default alt art in the deck until it gets another illustration); a + * touched illustration goes on the first free copy, one dragged onto a copy on that copy. An illustration whose owned + * copies are all placed waits. Each change is saved with the deck. */ @Component({ selector: 'app-alt-art-picker', @@ -32,8 +32,8 @@ export interface AltArtPickerData { export class AltArtPickerOverlay { protected readonly ref = inject>(AcOverlayRef); private readonly data = this.ref.data!; - /** Each copy's illustration chosen in this deck, `null` for a copy left to its default alt art. */ - protected readonly chosen = signal(slotChoices(this.data.choice, this.data.prints())); + /** Each copy's illustration, `null` for a freed copy (its default alt art in the deck); copy 1 the deck's own choice. */ + protected readonly chosen = signal<(string | null)[]>(slotDefaults(this.data.choice, slotChoices(this.data.choice, this.data.prints()))); /** Each copy's illustration in the deck. */ private readonly prints = computed(() => slotDefaults(this.data.choice, this.chosen())); protected readonly slots = computed(() => { @@ -41,7 +41,7 @@ export class AltArtPickerOverlay { return this.prints().map((reference, i) => ({ src: cardImageUrl(reference), label: copyLabel(i + 1), - chosen: chosen[i] !== null, + free: chosen[i] === null, freeLabel: freeLabel(i + 1), })); }); @@ -61,7 +61,7 @@ export class AltArtPickerOverlay { }; }); }); - protected readonly allDefault = computed(() => this.chosen().every((c) => c === null)); + protected readonly allDefault = computed(() => this.chosen().join() === defaultPrints(this.data.choice, this.chosen().length).join()); /** The site's « Arts alternatifs par défaut » page (plugin ownership). */ protected readonly settingsUrl = `${environment.siteUrl.replace(/\/$/, '')}/pages/ownership-alt-arts`; /** A long press starts a drag on touch screens, so that a swipe still scrolls the illustrations. */ @@ -80,13 +80,13 @@ export class AltArtPickerOverlay { if (event.container !== event.previousContainer) this.choose(event.container.data, event.item.data); } - /** A touched copy: back to its default alt art. */ + /** A touched copy: free for the next illustration touched. */ protected free(index: number): void { this.update(this.chosen().map((c, i) => (i === index ? null : c))); } protected resetToDefaults(): void { - this.update(this.chosen().map(() => null)); + this.update(defaultPrints(this.data.choice, this.chosen().length)); } private choose(index: number, reference: string): void { @@ -109,7 +109,7 @@ export class AltArtPickerOverlay { const copyLabel = (n: number) => $localize`:@@altArt.marker:Exemplaire ${n}:n:`; const printLabel = (n: number) => $localize`:@@altArt.tile:Illustration ${n}:n:`; -const freeLabel = (n: number) => $localize`:@@editor.altArtPicker.free:Exemplaire ${n}:n:, illustration choisie : rendre l’art par défaut`; +const freeLabel = (n: number) => $localize`:@@editor.altArtPicker.free:Exemplaire ${n}:n: : le libérer`; const unlimited = () => $localize`:@@editor.altArtPicker.unlimited:Illimitée`; const notOwned = () => $localize`:@@editor.altArtPicker.notOwned:Non possédée`; const placedLabel = (placed: number, owned: number) => diff --git a/plugins/rebuilder/app/src/locale/messages.en.json b/plugins/rebuilder/app/src/locale/messages.en.json index e64f8650..7782481a 100644 --- a/plugins/rebuilder/app/src/locale/messages.en.json +++ b/plugins/rebuilder/app/src/locale/messages.en.json @@ -777,10 +777,10 @@ "editor.overview.openDeck": "Open the Deck tab", "editor.changeHeroOf": "Change hero ({$name})", "editor.overviewOf": "Deck summary {$name}", - "editor.altArtPicker.hint": "Touch an illustration to put it on the next copy, or drag it onto the copy you want. Faded: the default alt art.", - "editor.altArtPicker.hintFull": "Every copy has its illustration. Touch a copy to give it back its default alt art, or drag an illustration onto it.", - "editor.altArtPicker.default": "Default", - "editor.altArtPicker.free": "Copy {$n}, illustration chosen: back to the default alt art", + "editor.altArtPicker.hint": "Touch an illustration to put it on the free copy, or drag it onto the copy you want.", + "editor.altArtPicker.hintFull": "Touch a copy to free it, or drag an illustration onto it.", + "editor.altArtPicker.empty": "Free", + "editor.altArtPicker.free": "Copy {$n}: free it", "editor.altArtPicker.copies": "Copies", "editor.altArtPicker.available": "Available illustrations", "editor.altArtPicker.settings": "Edit my default alt arts", diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index 227d500d..fa8fa871 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -1164,24 +1164,27 @@ test.describe('ReBuilder in the shell · deck page', () => { await page.getByRole('button', { name: 'Choisir les illustrations de Élémentaire de Kélon' }).click(); const dialog = page.getByRole('dialog', { name: 'Élémentaire de Kélon' }); const copies = dialog.getByRole('list', { name: 'Exemplaires' }).getByRole('listitem'); - const chosen = (n: number) => dialog.getByRole('button', { name: `Exemplaire ${n}, illustration choisie : rendre l’art par défaut` }); - // Three copies on their default alt art (the plain print here), faded. + const copy = (n: number) => dialog.getByRole('button', { name: `Exemplaire ${n} : le libérer` }); + const free = copies.getByText('Libre', { exact: true }); + // Three copies on their default alt art (the plain print here), shown as chosen. await expect(copies).toHaveCount(3); - await expect(copies.getByText('Par défaut', { exact: true })).toHaveCount(3); - // A touched illustration goes on the first free copy: the deck is saved with it. + await expect(copy(1)).toBeVisible(); + await expect(free).toHaveCount(0); + // A touched copy is free; a touched illustration goes on it: the deck is saved with it. + await copy(1).click(); + await expect(free).toHaveCount(1); let saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 1 && c.get(kelon.base) === 2); await dialog.getByRole('button', { name: /^Illustration 2,/ }).click(); await saved; - await expect(chosen(1)).toBeVisible(); - await expect(copies.getByText('Par défaut', { exact: true })).toHaveCount(2); + await expect(free).toHaveCount(0); // The only alt art owned is placed: it waits. await expect(dialog.getByRole('button', { name: /^Illustration 2,/ })).toHaveAttribute('aria-disabled', 'true'); await evidence(page, testInfo, '30-alt-art-brush'); - // A touched copy goes back to its default alt art. + // Freed, the copy keeps its default alt art in the deck. saved = savedCards(page, id, (c) => !c.has(kelon.alt()) && c.get(kelon.base) === 3); - await chosen(1).click(); + await copy(1).click(); await saved; - await expect(copies.getByText('Par défaut', { exact: true })).toHaveCount(3); + await expect(free).toHaveCount(1); // An illustration dragged onto a copy goes on that copy. saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 1 && c.get(kelon.base) === 2); const from = (await dialog.getByRole('button', { name: /^Illustration 2,/ }).boundingBox())!; @@ -1191,8 +1194,7 @@ test.describe('ReBuilder in the shell · deck page', () => { await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 }); await page.mouse.up(); await saved; - await expect(chosen(3)).toBeVisible(); - await expect(chosen(1)).toHaveCount(0); + await expect(free).toHaveCount(1); }); test('takes a card brought to 0 off the deck board', async ({ page, compact }) => { @@ -1218,6 +1220,7 @@ test.describe('ReBuilder in the shell · deck page', () => { const piles = page.locator('app-deck-board ac-card-pile').filter({ has: page.getByRole('group', { name: 'Exemplaires de Élémentaire de Kélon dans le deck' }) }); // The alt art on copy 1 (the brush): the deck has two prints of the card. await page.getByRole('button', { name: 'Choisir les illustrations de Élémentaire de Kélon' }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Exemplaire 1 : le libérer' }).click(); const saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 1 && c.get(kelon.base) === 2); await page.getByRole('dialog').getByRole('button', { name: /^Illustration 2,/ }).click(); await saved; From d0dfa47f285fbaaa58926ec7eb973a05f8a9daee Mon Sep 17 00:00:00 2001 From: Yutsa Date: Thu, 8 Oct 2026 17:16:49 +0200 Subject: [PATCH 07/38] =?UTF-8?q?Re:Builder:=20the=20save=20status=20befor?= =?UTF-8?q?e=20=C2=AB=20Regrouper=20les=20illustrations=20=C2=BB,=20so=20t?= =?UTF-8?q?hat=20saving=20moves=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../src/app/features/editor/editor-page/editor.page.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html index 23dd1d73..939088f7 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html @@ -135,12 +135,12 @@ Survolez une pile pour changer le nombre d’exemplaires. } - @if (effectiveView() === 'apercu' && !bp.compact() && altArts.canChoose()) { - - } @if (deck.editable()) { } + @if (effectiveView() === 'apercu' && !bp.compact() && altArts.canChoose()) { + + } @if (altArts.canChoose()) { From 765722e8619ec87a22773d1c8bd189d2e297a13b Mon Sep 17 00:00:00 2001 From: Yutsa Date: Fri, 9 Oct 2026 22:22:17 +0200 Subject: [PATCH 08/38] Re:Builder brush: the illustrations with their 1st, 2nd and 3rd choice ribbons, and what the deck uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The brush lists the card's illustrations; touching one opens a menu to make it the 1st, 2nd or 3rd choice. The choices are the player's default alt arts (ownership service) and the deck's copies take them at once. A choice the deck does not use (beyond its copies) has a dashed ribbon; the « Ce que le deck utilise » panel fans out the copies. Co-Authored-By: Claude Opus 5.5 --- plugins/ownership/pages/alt-arts.php | 4 +- .../app/src/app/core/alt-art-defaults.spec.ts | 24 +- .../app/src/app/core/alt-art-defaults.ts | 25 ++ .../alt-art-picker.overlay.html | 117 ++++-- .../alt-art-picker.overlay.scss | 348 ++++++++++++------ .../alt-art-picker/alt-art-picker.overlay.ts | 205 +++++++---- .../features/editor/editor-alt-arts.spec.ts | 42 ++- .../app/features/editor/editor-alt-arts.ts | 39 +- .../rebuilder/app/src/locale/messages.en.json | 29 +- plugins/rebuilder/e2e/rebuilder.spec.ts | 67 ++-- 10 files changed, 622 insertions(+), 278 deletions(-) diff --git a/plugins/ownership/pages/alt-arts.php b/plugins/ownership/pages/alt-arts.php index 11b1433b..b2ed0222 100644 --- a/plugins/ownership/pages/alt-arts.php +++ b/plugins/ownership/pages/alt-arts.php @@ -29,7 +29,7 @@ 'saveError' => 'Could not save your choice.', 'loadMore' => 'Load more', 'markerHint' => 'Click a marker, then click a card to move it there.', - 'intro' => 'Choose the illustration each copy of a card takes when you add it to a deck (1st, 2nd and 3rd copy). In Re:Builder, the brush of a card changes its illustrations for one deck, and « Default alt arts » applies these choices to a deck again. A token\'s illustration is the same in all your decks.', + 'intro' => 'Choose the illustration each copy of a card takes when you add it to a deck (1st, 2nd and 3rd copy). In Re:Builder, the brush of a card changes these choices and the open deck takes them; « Default alt arts » applies them to a deck again. A token\'s illustration is the same in all your decks.', ], 'fr' => [ 'unavailable' => 'Le service de propriété numérique n\'est pas configuré sur ce site.', @@ -48,7 +48,7 @@ 'saveError' => 'Impossible d\'enregistrer votre choix.', 'loadMore' => 'Charger plus', 'markerHint' => 'Cliquez un marqueur, puis une carte pour l\'y déplacer.', - 'intro' => 'Choisissez l\'illustration que prend chaque exemplaire d\'une carte quand vous l\'ajoutez à un deck (1er, 2e et 3e exemplaire). Dans Re:Builder, le pinceau d\'une carte change ses illustrations pour un deck, et « Arts par défaut » réapplique ces choix à un deck. L\'illustration d\'un jeton est la même dans tous vos decks.', + 'intro' => 'Choisissez l\'illustration que prend chaque exemplaire d\'une carte quand vous l\'ajoutez à un deck (1er, 2e et 3e exemplaire). Dans Re:Builder, le pinceau d\'une carte modifie ces choix et le deck ouvert les prend ; « Arts par défaut » les réapplique à un deck. L\'illustration d\'un jeton est la même dans tous vos decks.', ], ][getUiLang()]; 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 index 2d38dd32..9fcbd62b 100644 --- a/plugins/rebuilder/app/src/app/core/alt-art-defaults.spec.ts +++ b/plugins/rebuilder/app/src/app/core/alt-art-defaults.spec.ts @@ -1,4 +1,4 @@ -import { addedCopyPrint, defaultPrints, familyPrints, linesWithDefaults, removedCopyPrint, sameCopies, slotChoices, slotDefaults, withFamilyPrints } from './alt-art-defaults'; +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'; @@ -44,6 +44,28 @@ describe('defaultPrints', () => { }); }); +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]); diff --git a/plugins/rebuilder/app/src/app/core/alt-art-defaults.ts b/plugins/rebuilder/app/src/app/core/alt-art-defaults.ts index 984a9aa0..553c2df8 100644 --- a/plugins/rebuilder/app/src/app/core/alt-art-defaults.ts +++ b/plugins/rebuilder/app/src/app/core/alt-art-defaults.ts @@ -24,6 +24,31 @@ 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. */ +export 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 slots = ranks.map((reference, i) => ({ slotIndex: i + 1, reference, isExplicitChoice: true })); + 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. 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 index 2f2ddef3..eed29099 100644 --- 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 @@ -1,40 +1,93 @@ -
    - @if (full()) { -

    Touchez un exemplaire pour le libérer, ou glissez une illustration dessus.

    - } @else { -

    Touchez une illustration pour la poser sur l’exemplaire libre, ou glissez-la sur l’exemplaire voulu.

    - } -
      - @for (s of slots(); track $index) { -
    • - {{ s.label }} -
      - @if (s.free) { -
      Libre
      - } @else { - +
      +
      +
      +

      Touchez une illustration pour choisir son rang. Le deck prend les choix dans l’ordre, un par exemplaire.

      +

      Ces choix sont vos arts par défaut pour cette carte : ils servent aussi quand vous l’ajoutez à un autre deck.

      +
      +
        + @for (a of arts(); track a.reference) { +
      • + + @if (a.ribbons.length) { + } -
      -
    • + {{ a.label }} + {{ a.note }} + + } +
    + @if (error(); as message) { + } -
-

Illustrations disponibles

-
    - @for (p of pool(); track p.reference) { -
  • - -
  • +
+
+ + + @let a = menuArt(); + + 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 index 89354b76..ba187c78 100644 --- 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 @@ -1,167 +1,215 @@ +/* The illustrations, then what the deck uses: side by side, the deck under the illustrations on a phone. */ .ac-overlay-body { display: flex; - flex-direction: column; - align-items: center; - gap: var(--ac-space-3); + flex-wrap: wrap; + align-items: flex-start; + gap: var(--ac-space-6); } -.hint { +p, +h3, +ul, +ol { margin: 0; - text-align: center; - font-size: var(--ac-font-size-small); - color: var(--ac-color-text-muted); } -ul { - margin: 0; +ul, +ol { padding: 0; list-style: none; } -/* The copies, large: up to 200 px each, narrower on a phone. */ -.slots { - width: 100%; - display: grid; - grid-template-columns: repeat(var(--copies, 1), minmax(0, 200px)); - justify-content: center; - gap: var(--ac-space-2); - padding: var(--ac-space-2); - border-radius: var(--ac-radius-lg); - background: var(--ac-color-primary-tint); - box-sizing: border-box; +.pool { + flex: 999 1 420px; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--ac-space-4); } -.slots li { +.intro { display: flex; flex-direction: column; - align-items: center; gap: var(--ac-space-1); - min-width: 0; } -.slot-label { - font-size: var(--ac-font-size-caption); - font-weight: 700; +.hint { + font: var(--ac-font-body); + color: var(--ac-color-text-2); +} +.defaults-note { + font: var(--ac-font-small); color: var(--ac-color-text-muted); } -.slot, +.prints { + --ribbon-width: 52px; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--ac-space-6) var(--ac-space-10); +} +/* An illustration, its ribbons hanging over its top edge. */ .print { + position: relative; + width: min(200px, calc(50% - var(--ac-space-3))); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ac-space-0-5); + padding-top: var(--ac-space-3); + text-align: center; +} +.art { + display: block; + width: 100%; + margin-bottom: var(--ac-space-2); padding: 0; border: 0; + border-radius: var(--ac-radius-lg); background: none; cursor: pointer; - font: inherit; - color: inherit; + transition: transform var(--ac-duration-base); } -/* A copy: its illustration, a drop target for the illustrations below. */ -.slot-drop { - position: relative; +.art img { + display: block; width: 100%; - border-radius: var(--ac-radius-md); - outline: 2px dashed transparent; - outline-offset: 2px; + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: var(--ac-radius-lg); +} +.art[aria-expanded='true'] { + box-shadow: var(--ac-ring-selected); +} +.art:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: 3px; +} +.art:disabled { + cursor: not-allowed; } -.slot-drop.cdk-drop-list-dragging { - outline-color: var(--ac-color-primary); +.art:disabled img { + opacity: 0.4; +} +@media (hover: hover) { + .art:not(:disabled):hover { + transform: translateY(calc(-1 * var(--ac-space-1))); + } +} +.print-label { + font: var(--ac-font-body-strong); + color: var(--ac-color-text); } -/* The dragged illustration's place holder: out of the copy's layout. */ -.slot-drop .cdk-drag-placeholder { +.print-note { + font: var(--ac-font-caption); + color: var(--ac-color-text-muted); +} +/* The choices an illustration holds, 1st on the left: plain when the deck uses them, dashed otherwise. */ +.ribbons { position: absolute; - inset: 0; - opacity: 0; + top: 0; + right: var(--ac-space-2-5); + display: flex; + gap: var(--ac-space-1); pointer-events: none; } -.slot { +.ribbon { position: relative; display: block; - width: 100%; - border-radius: var(--ac-radius-md); + width: var(--ribbon-width); + aspect-ratio: 52 / 70; + color: var(--ac-color-on-primary); } -.slot img { - display: block; +.ribbon svg { + position: absolute; + inset: 0; width: 100%; - aspect-ratio: 5 / 7; - object-fit: cover; - border-radius: var(--ac-radius-md); + height: 100%; + overflow: visible; } -/* A freed copy: waiting for an illustration. */ -.slot.empty { - display: grid; - place-items: center; - aspect-ratio: 5 / 7; - box-sizing: border-box; - border: 2px dashed var(--ac-color-border); - background: var(--ac-color-surface); - font-size: var(--ac-font-size-small); - font-weight: 700; - color: var(--ac-color-text-muted); +.ribbon path { + fill: var(--ac-color-primary); + stroke: var(--ac-color-surface); + stroke-width: 2; } -/* A copy's ✕: touched, the copy is free. */ -.free { - position: absolute; - top: var(--ac-space-1); - right: var(--ac-space-1); - display: grid; - place-items: center; - width: var(--ac-control-sm); - height: var(--ac-control-sm); - border-radius: 50%; - background: var(--ac-color-surface); - box-shadow: var(--ac-shadow-e3); - color: var(--ac-color-text); -} -.pool-title { - margin: 0; - font-size: var(--ac-font-size-caption); - font-weight: 700; +.ribbon.unused { color: var(--ac-color-text-muted); } -/* The prints in one row, scrolled sideways when they do not fit. */ -.pool { - max-width: 100%; +.ribbon.unused path { + fill: var(--ac-color-surface); + stroke: var(--ac-color-text-muted); + stroke-dasharray: 4 3; +} +.ribbon-text { + position: relative; display: flex; - justify-content: safe center; - gap: var(--ac-space-2); - overflow-x: auto; - padding-bottom: var(--ac-space-1); + flex-direction: column; + align-items: center; + gap: var(--ac-space-0-5); + padding-top: 18%; } -.pool li { - flex-shrink: 0; +.ribbon-num { + font-family: var(--ac-font-family); + font-size: var(--ac-font-size-title); + font-weight: 800; + line-height: 1; } -.print { - width: 126px; +.ribbon-num sup { + font-size: var(--ac-font-size-micro); +} +.ribbon-word { + font: var(--ac-font-micro); +} +.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: 1 1 260px; + box-sizing: border-box; display: flex; flex-direction: column; - align-items: center; - gap: var(--ac-space-1); - padding: var(--ac-space-2) var(--ac-space-1) var(--ac-space-1); - border: 1px solid var(--ac-color-border); - border-radius: var(--ac-radius-md); - background: var(--ac-color-surface); + gap: var(--ac-space-4); + padding: var(--ac-space-5); + border-radius: var(--ac-radius-card); + background: var(--ac-color-bg-app); } -.print img { - display: block; - width: 110px; +.deck h3 { + font: var(--ac-font-heading); + color: var(--ac-color-text); +} +.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%; } -.print.off { - cursor: not-allowed; -} -.print.off img { - opacity: 0.4; -} -.pool .cdk-drag-preview .print, -.pool .cdk-drag-placeholder .print { - box-shadow: var(--ac-shadow-e2); +.summary { + font: var(--ac-font-body-strong); + color: var(--ac-color-text); } -.pool .cdk-drag-placeholder { - opacity: 0.5; +.uses { + display: flex; + flex-direction: column; + gap: var(--ac-space-1); } -.note { - font-size: var(--ac-font-size-caption); - color: var(--ac-color-text-muted); +.uses li { + display: flex; + justify-content: space-between; + gap: var(--ac-space-2); + font: var(--ac-font-small); + color: var(--ac-color-text-2); } -.slot:focus-visible, -.print:focus-visible { - outline: 2px solid var(--ac-color-focus); - outline-offset: 2px; +.use { + color: var(--ac-color-text); + text-align: right; } .settings { margin-right: auto; @@ -169,11 +217,71 @@ ul { font-size: var(--ac-font-size-small); color: var(--ac-color-link); } +/* The choices' menu of an illustration. */ +.menu { + box-sizing: border-box; + min-width: 200px; + max-width: calc(100vw - 2 * var(--ac-space-4)); + padding: var(--ac-space-1-5); + display: flex; + flex-direction: column; + background: var(--ac-color-surface); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-lg); + box-shadow: var(--ac-shadow-e2); +} +.menu-title { + padding: var(--ac-space-1) var(--ac-space-2-5); + font: var(--ac-font-caption); + color: var(--ac-color-text-muted); +} +.item { + min-height: var(--ac-control-md); + padding: var(--ac-space-1) var(--ac-space-2-5); + border: 0; + border-radius: var(--ac-radius-md); + background: transparent; + color: var(--ac-color-text); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ac-space-3); + text-align: left; + font: var(--ac-font-body); + cursor: pointer; +} +.item[aria-checked='true'] { + background: var(--ac-color-primary-tint); + color: var(--ac-color-primary-strong); +} +.item[aria-disabled='true'] { + opacity: 0.5; + cursor: not-allowed; +} +.item:focus-visible { + background: var(--ac-color-bg-subtle); + outline: none; +} +@media (hover: hover) { + .item:not([aria-disabled='true']):hover { + background: var(--ac-color-bg-subtle); + } +} +.item-label.unused { + color: var(--ac-color-text-muted); +} +.holder { + width: 22px; + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: var(--ac-radius-xs); +} @media (max-width: 767px) { - .print { - width: 96px; + .prints { + --ribbon-width: 40px; + gap: var(--ac-space-5) var(--ac-space-4); } - .print img { - width: 84px; + .ribbon-num { + font-size: var(--ac-font-size-body-strong); } } 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 index 521e353f..184bd9c0 100644 --- 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 @@ -1,7 +1,10 @@ -import { CdkDrag, CdkDropList, CdkDropListGroup, type CdkDragDrop } from '@angular/cdk/drag-drop'; +import { CdkMenu, CdkMenuItemRadio, CdkMenuTrigger } from '@angular/cdk/menu'; +import type { ConnectedPosition } from '@angular/cdk/overlay'; +import { HttpErrorResponse } from '@angular/common/http'; import { Component, computed, inject, signal } from '@angular/core'; +import type { Observable } from 'rxjs'; import { environment } from '../../../../environments/environment'; -import { defaultPrints, slotChoices, slotDefaults } from '../../../core/alt-art-defaults'; +import { canRank, defaultPrints, rankPrints, sameCopies, slotChoices, slotDefaults } from '../../../core/alt-art-defaults'; import { cardImageUrl } from '../../../core/card-art'; import type { AltArtChoice } from '../../../core/ownership-api.service'; import { AcButton } from '../../../ui/buttons'; @@ -9,22 +12,31 @@ import { AcIcon } from '../../../ui/icon'; import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; export interface AltArtPickerData { - choice: AltArtChoice; + /** The card's family, with the player's default alt arts as they are now. */ + choice: () => AltArtChoice; /** The family's copies in the deck, one print a copy. */ prints: () => readonly string[]; /** The family's copies take `prints`, one print a copy. */ set: (prints: readonly string[]) => void; + /** The family's default alt arts become `ranks` (1st choice first) and the deck's copies take them; errors reach the caller. */ + setDefaults: (ranks: readonly string[]) => Observable; } +/** The choices' menu: over the bottom of the illustration, under it when there is no room. */ +const MENU_POSITIONS: ConnectedPosition[] = [ + { originX: 'center', originY: 'bottom', overlayX: 'center', overlayY: 'bottom', offsetY: -8 }, + { originX: 'center', originY: 'bottom', overlayX: 'center', overlayY: 'top', offsetY: 8 }, +]; + /** - * The brush of a card: the illustration of each of its copies in this deck, the default alt arts as chosen as the - * others. A touched copy is freed (it keeps its default alt art in the deck until it gets another illustration); a - * touched illustration goes on the first free copy, one dragged onto a copy on that copy. An illustration whose owned - * copies are all placed waits. Each change is saved with the deck. + * The brush of a card: its illustrations, each with the choices it holds (1st, 2nd, 3rd: the player's default alt arts + * for the card, one a copy), and what the deck uses. A deck takes the choices in order, one a copy: with 2 copies, the + * 1st and 2nd; the 3rd choice shows apart. A choice changed here is saved as a default alt art (ownership service) and + * the deck's copies take the defaults at once. */ @Component({ selector: 'app-alt-art-picker', - imports: [AcButton, AcIcon, CdkDropListGroup, CdkDropList, CdkDrag], + imports: [AcButton, AcIcon, CdkMenuTrigger, CdkMenu, CdkMenuItemRadio], host: { class: 'ac-overlay-content' }, templateUrl: './alt-art-picker.overlay.html', styleUrl: './alt-art-picker.overlay.scss', @@ -32,90 +44,137 @@ export interface AltArtPickerData { export class AltArtPickerOverlay { protected readonly ref = inject>(AcOverlayRef); private readonly data = this.ref.data!; - /** Each copy's illustration, `null` for a freed copy (its default alt art in the deck); copy 1 the deck's own choice. */ - protected readonly chosen = signal<(string | null)[]>(slotDefaults(this.data.choice, slotChoices(this.data.choice, this.data.prints()))); - /** Each copy's illustration in the deck. */ - private readonly prints = computed(() => slotDefaults(this.data.choice, this.chosen())); - protected readonly slots = computed(() => { - const chosen = this.chosen(); - return this.prints().map((reference, i) => ({ - src: cardImageUrl(reference), - label: copyLabel(i + 1), - free: chosen[i] === null, - freeLabel: freeLabel(i + 1), - })); - }); - protected readonly full = computed(() => !this.chosen().includes(null)); - protected readonly pool = computed(() => { - const prints = this.prints(); - const chosen = this.chosen(); - return this.data.choice.options.options.map((o, i) => { - const placed = prints.filter((p) => p === o.reference).length; - const owned = o.ownedQuantity; + private readonly choice = computed(() => this.data.choice()); + /** The illustration of each choice, 1st first. */ + private readonly ranks = computed(() => rankPrints(this.choice())); + private readonly copies = computed(() => this.data.prints().length); + /** The deck's copies follow the default alt arts. */ + protected readonly followsDefaults = computed(() => sameCopies(this.data.prints(), defaultPrints(this.choice(), this.copies()))); + protected readonly saving = signal(false); + protected readonly error = signal(null); + protected readonly positions = MENU_POSITIONS; + protected readonly choiceWord = choiceWord(); + /** The site's « Arts alternatifs par défaut » page (plugin ownership). */ + protected readonly settingsUrl = `${environment.siteUrl.replace(/\/$/, '')}/pages/ownership-alt-arts`; + + protected readonly arts = computed(() => { + const choice = this.choice(); + const ranks = this.ranks(); + const copies = this.copies(); + return choice.options.options.map((o, i) => { + const label = printLabel(i + 1); + const held = ranks.flatMap((r, k) => (r === o.reference ? [k] : [])); return { reference: o.reference, src: cardImageUrl(o.reference), - label: printLabel(i + 1), - off: this.taken(o.reference, chosen), - note: owned === null ? unlimited() : owned === 0 ? notOwned() : placedLabel(placed, owned), + label, + note: o.ownedQuantity === null ? unlimited() : o.ownedQuantity === 0 ? notOwned() : ownedLabel(o.ownedQuantity), + unowned: o.ownedQuantity === 0, + ariaLabel: artLabel([label, ...held.map(rankLabel)].join(', ')), + menuLabel: menuLabel(label), + ribbons: held.map((k) => ({ + rank: k, + num: k + 1, + suffix: rankSuffix(k), + used: k < copies, + title: k < copies ? rankLabel(k) : unusedLabel(rankLabel(k), copies), + })), + options: ranks.map((r, k) => ({ + rank: k, + label: rankLabel(k), + used: k < copies, + checked: r === o.reference, + disabled: !canRank(choice, ranks, k, o.reference), + holder: cardImageUrl(r), + })), }; }); }); - protected readonly allDefault = computed(() => this.chosen().join() === defaultPrints(this.data.choice, this.chosen().length).join()); - /** The site's « Arts alternatifs par défaut » page (plugin ownership). */ - protected readonly settingsUrl = `${environment.siteUrl.replace(/\/$/, '')}/pages/ownership-alt-arts`; - /** A long press starts a drag on touch screens, so that a swipe still scrolls the illustrations. */ - protected readonly dragDelay = { touch: 300, mouse: 0 }; - /** Nothing is dropped on the illustrations. */ - protected readonly noDrop = () => false; - - /** A touched illustration: on the first free copy. */ - protected place(reference: string): void { - const index = this.chosen().indexOf(null); - if (index !== -1) this.choose(index, reference); - } - - /** An illustration dropped on a copy. */ - protected drop(event: CdkDragDrop): void { - if (event.container !== event.previousContainer) this.choose(event.container.data, event.item.data); - } - /** A touched copy: free for the next illustration touched. */ - protected free(index: number): void { - this.update(this.chosen().map((c, i) => (i === index ? null : c))); - } + /** The illustration whose menu is open (the CDK keeps a menu's first context: the menu reads it from here). */ + protected readonly menuFor = signal(null); + protected readonly menuArt = computed(() => this.arts().find((a) => a.reference === this.menuFor()) ?? null); - protected resetToDefaults(): void { - this.update(defaultPrints(this.data.choice, this.chosen().length)); - } + /** The deck's copies, copy 1 first (as on the deck board), with the choice each one takes. */ + protected readonly preview = computed(() => { + const choice = this.choice(); + const ranks = this.ranks(); + const follows = this.followsDefaults(); + const options = choice.options.options; + return slotDefaults(choice, slotChoices(choice, this.data.prints())).map((print, i) => { + const rank = Math.min(i, ranks.length - 1); + const label = printLabel(options.findIndex((o) => o.reference === print) + 1); + return { + src: cardImageUrl(print), + copy: copyLabel(i + 1), + use: follows && ranks[rank] === print ? `${rankLabel(rank)} · ${label}` : label, + }; + }); + }); - private choose(index: number, reference: string): void { - const chosen = this.chosen(); - if (chosen[index] === reference || this.taken(reference, chosen.map((c, i) => (i === index ? null : c)))) return; - this.update(chosen.map((c, i) => (i === index ? reference : c))); - } + protected readonly summary = computed(() => { + const n = this.copies(); + if (n === 0) return summaryNone(); + if (!this.followsDefaults()) return summaryOwn(); + return n === 1 ? summaryOne() : n === 2 ? summaryTwo() : summaryAll(n); + }); - /** Every owned copy of `reference` is chosen (or the player owns none). */ - private taken(reference: string, chosen: readonly (string | null)[]): boolean { - const owned = this.data.choice.options.options.find((o) => o.reference === reference)?.ownedQuantity ?? null; - return owned !== null && chosen.filter((c) => c === reference).length >= owned; + /** Choice `rank` takes the illustration of the open menu: saved as a default alt art, the deck's copies follow. */ + protected choose(rank: number): void { + const reference = this.menuFor(); + const ranks = this.ranks(); + if (reference === null || ranks[rank] === reference || this.saving()) return; + this.error.set(null); + this.saving.set(true); + this.data.setDefaults(ranks.map((r, k) => (k === rank ? reference : r))).subscribe({ + complete: () => this.saving.set(false), + error: (err: unknown) => { + this.saving.set(false); + this.error.set(err instanceof HttpErrorResponse && err.status === 409 ? notEnoughCopies() : saveFailed()); + }, + }); } - private update(chosen: (string | null)[]): void { - this.chosen.set(chosen); - this.data.set(this.prints()); + /** « Arts par défaut »: the deck's copies take the choices. */ + protected resetToDefaults(): void { + this.data.set(defaultPrints(this.choice(), this.copies())); } } const copyLabel = (n: number) => $localize`:@@altArt.marker:Exemplaire ${n}:n:`; const printLabel = (n: number) => $localize`:@@altArt.tile:Illustration ${n}:n:`; -const freeLabel = (n: number) => $localize`:@@editor.altArtPicker.free:Exemplaire ${n}:n: : le libérer`; +const rankLabel = (k: number) => + k === 0 + ? $localize`:@@editor.altArtPicker.rank1:1er choix` + : k === 1 + ? $localize`:@@editor.altArtPicker.rank2:2e choix` + : $localize`:@@editor.altArtPicker.rank3:3e choix`; +/** Ordinal suffix on a ribbon (« 1er », « 2e »). */ +const rankSuffix = (k: number) => + k === 0 + ? $localize`:@@editor.altArtPicker.suffix1:er` + : k === 1 + ? $localize`:@@editor.altArtPicker.suffix2:e` + : $localize`:@@editor.altArtPicker.suffix3:e`; +const choiceWord = () => $localize`:@@editor.altArtPicker.choiceWord:choix`; +const unusedLabel = (rank: string, copies: number) => + copies > 1 + ? $localize`:@@editor.altArtPicker.unusedMany:${rank}:rank: : pas utilisé avec ${copies}:n: exemplaires` + : $localize`:@@editor.altArtPicker.unusedOne:${rank}:rank: : pas utilisé avec 1 exemplaire`; +const artLabel = (text: string) => $localize`:@@editor.altArtPicker.art:${text}:text: : choisir son rang`; +const menuLabel = (label: string) => $localize`:@@editor.altArtPicker.menu:Rang de ${label}:label:`; const unlimited = () => $localize`:@@editor.altArtPicker.unlimited:Illimitée`; const notOwned = () => $localize`:@@editor.altArtPicker.notOwned:Non possédée`; -const placedLabel = (placed: number, owned: number) => - placed > 1 - ? $localize`:@@editor.altArtPicker.placedMany:${placed}:placed: / ${owned}:owned: placées` - : $localize`:@@editor.altArtPicker.placedOne:${placed}:placed: / ${owned}:owned: placé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 summaryOwn = () => + $localize`:@@editor.altArtPicker.summaryOwn:Ce deck garde ses propres illustrations : « Arts par défaut » lui applique vos choix.`; +const summaryOne = () => $localize`:@@editor.altArtPicker.summaryOne:Avec 1 exemplaire, le deck prend le 1er choix.`; +const summaryTwo = () => $localize`:@@editor.altArtPicker.summaryTwo:Avec 2 exemplaires, le deck prend les 1er et 2e choix.`; +const summaryAll = (n: number) => $localize`:@@editor.altArtPicker.summaryAll:Avec ${n}:n: exemplaires, le deck prend les trois choix.`; +const notEnoughCopies = () => $localize`:@@altArt.notEnoughCopies:Vous n’avez pas assez d’exemplaires de cet art alternatif.`; +const saveFailed = () => $localize`:@@altArt.saveError:Impossible d’enregistrer votre choix.`; /** 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 { diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.spec.ts b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.spec.ts index 11f8118e..93546500 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.spec.ts +++ b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { signal } from '@angular/core'; -import { Subject, of } from 'rxjs'; +import { Subject, of, throwError } from 'rxjs'; import { AuthSession } from '../../core/auth-session'; import { DeckStore } from '../../core/deck-store'; import type { Card, HydratedLine } from '../../core/models'; @@ -27,16 +27,28 @@ describe('EditorAltArts', () => { let answers: Subject>; let lines: ReturnType>; let familyAdds: number[]; + let saved: string[][]; + let saveFails: boolean; function setup(): EditorAltArts { answers = new Subject(); lines = signal([]); familyAdds = []; + saved = []; + saveFails = false; TestBed.configureTestingModule({ providers: [ EditorAltArts, { provide: AuthSession, useValue: { isLoggedIn: () => true } }, - { provide: OwnershipApiService, useValue: { baseUrl: '/ownership', altArtChoices: () => answers, pendingDefaults: () => of(false) } }, + { + provide: OwnershipApiService, + useValue: { + baseUrl: '/ownership', + altArtChoices: () => answers, + pendingDefaults: () => of(false), + setAltArtPreference: (_family: unknown, slots: string[]) => (saveFails ? throwError(() => new Error('down')) : (saved.push(slots), of(undefined))), + }, + }, { provide: DeckStore, useValue: { @@ -49,6 +61,8 @@ describe('EditorAltArts', () => { quantityOf: (ref: string) => lines().find((l) => l.card.reference === ref)?.quantity ?? 0, setQuantity: () => undefined, setFamilyQuantity: (_card: Card, _choice: AltArtChoice, _members: Set, n: number) => familyAdds.push(n), + setFamilyPrints: (_members: Set, _card: Card, prints: string[]) => + lines.set([...new Set(prints)].map((reference) => ({ card: card(reference), quantity: prints.filter((p) => p === reference).length }))), }, }, ], @@ -78,4 +92,28 @@ describe('EditorAltArts', () => { expect(alt.maxFor(card(BASE), true)).toBe(3); expect(alt.familyQuantity(card(BASE))).toBe(3); }); + + it('saves the brush’s choices as the default alt arts, the deck’s copies taking them', () => { + const alt = setup(); + alt.request([BASE]); + answers.next({ [BASE]: kelon }); + lines.set([{ card: card(BASE), quantity: 2 }]); + alt.setDefaults(card(BASE), [ALT, BASE, ALT]).subscribe(); + expect(saved).toEqual([[ALT, BASE, ALT]]); + expect(lines().map((l) => [l.card.reference, l.quantity])).toEqual([[ALT, 1], [BASE, 1]]); + expect(alt.pickerFor(card(BASE))!.choice().options.slots.map((s) => s.reference)).toEqual([ALT, BASE, ALT]); + }); + + it('puts the defaults and the copies back when the choices cannot be saved', () => { + const alt = setup(); + alt.request([BASE]); + answers.next({ [BASE]: kelon }); + lines.set([{ card: card(BASE), quantity: 2 }]); + saveFails = true; + let failed = false; + alt.setDefaults(card(BASE), [BASE, BASE, BASE]).subscribe({ error: () => (failed = true) }); + expect(failed).toBe(true); + expect(lines().map((l) => [l.card.reference, l.quantity])).toEqual([[BASE, 2]]); + expect(alt.choiceFor(BASE)).toBe(kelon); + }); }); diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts index e2bff2df..1dbe5a93 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts +++ b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts @@ -1,8 +1,8 @@ import { Service, computed, effect, inject, signal, untracked } from '@angular/core'; -import { type Observable, filter, map, of, switchMap, take } from 'rxjs'; +import { EMPTY, type Observable, catchError, filter, map, of, switchMap, take, throwError } from 'rxjs'; import { toObservable } from '@angular/core/rxjs-interop'; import { AuthSession } from '../../core/auth-session'; -import { basePrint, familyPrints, slotChoices, slotDefaults } from '../../core/alt-art-defaults'; +import { basePrint, defaultPrints, familyPrints, slotChoices, slotDefaults, withRanks } from '../../core/alt-art-defaults'; import { DeckStore } from '../../core/deck-store'; import { storedFlag } from '../../core/stored-flag'; import type { Card } from '../../core/models'; @@ -16,9 +16,9 @@ const CHUNK = 60; /** * Illustrations in the editor (`core/alt-art-defaults.ts`): a card added from the search takes the player's default alt - * art for its copy, the brush of a card changes its prints for this deck, « Appliquer les arts par défaut » rewrites the - * deck with the defaults, and a print used more times than owned is flagged (Board Game Arena shows the base art for the - * missing copies). Needs the ownership service and a signed-in player; nothing happens without. + * art for its copy, the brush of a card sets its default alt arts (this deck's copies take them), « Appliquer les arts + * par défaut » rewrites the deck with the defaults, and a print used more times than owned is flagged (Board Game Arena + * shows the base art for the missing copies). Needs the ownership service and a signed-in player; nothing happens without. */ @Service({ autoProvided: false }) export class EditorAltArts { @@ -209,12 +209,39 @@ export class EditorAltArts { const choice = this.choiceFor(card.reference); if (!choice || !this.canIllustrate(card)) return null; return { - choice, + choice: () => this.choiceFor(card.reference) ?? choice, prints: () => familyPrints(this.deck.lines(), this.members(choice)), set: (prints) => this.deck.setFamilyPrints(this.members(choice), card, prints), + setDefaults: (ranks) => this.setDefaults(card, ranks), }; } + /** + * The default alt arts of `card`'s family become `ranks` (1st choice first): this deck's copies of the family take them + * at once, then the ownership service saves them; on an error, the previous defaults and copies come back. + */ + setDefaults(card: Card, ranks: readonly string[]): Observable { + const choice = this.choiceFor(card.reference); + if (!choice) return EMPTY; + const before = familyPrints(this.deck.lines(), this.members(choice)); + const next = withRanks(choice, ranks); + this.replaceChoice(next); + this.deck.setFamilyPrints(this.members(choice), card, defaultPrints(next, before.length)); + return this.ownership.setAltArtPreference(choice.family, [...ranks]).pipe( + catchError((err: unknown) => { + this.replaceChoice(choice); + this.deck.setFamilyPrints(this.members(choice), card, before); + return throwError(() => err); + }), + ); + } + + /** Every print of `choice`'s family is known with `choice`. */ + private replaceChoice(choice: AltArtChoice): void { + const key = familyKey(choice.family); + this.known.update((m) => new Map([...m].map(([ref, c]) => [ref, c && familyKey(c.family) === key ? choice : c]))); + } + /** « Appliquer les arts par défaut », once the deck's families are known: `false` when the deck already has them. */ applyDefaults(): Observable { return this.ready$.pipe(map(() => this.deck.applyAltArtDefaults(this.choices()))); diff --git a/plugins/rebuilder/app/src/locale/messages.en.json b/plugins/rebuilder/app/src/locale/messages.en.json index 7782481a..aa7658eb 100644 --- a/plugins/rebuilder/app/src/locale/messages.en.json +++ b/plugins/rebuilder/app/src/locale/messages.en.json @@ -777,19 +777,34 @@ "editor.overview.openDeck": "Open the Deck tab", "editor.changeHeroOf": "Change hero ({$name})", "editor.overviewOf": "Deck summary {$name}", - "editor.altArtPicker.hint": "Touch an illustration to put it on the free copy, or drag it onto the copy you want.", - "editor.altArtPicker.hintFull": "Touch a copy to free it, or drag an illustration onto it.", - "editor.altArtPicker.empty": "Free", - "editor.altArtPicker.free": "Copy {$n}: free it", - "editor.altArtPicker.copies": "Copies", + "editor.altArtPicker.hint": "Touch an illustration to choose its rank. The deck takes the choices in order, one per copy.", + "editor.altArtPicker.defaultsNote": "These choices are your default alt arts for this card: they also apply when you add it to another deck.", + "editor.altArtPicker.deckTitle": "What the deck uses", + "editor.altArtPicker.useAs": "Use as…", + "editor.altArtPicker.rank1": "1st choice", + "editor.altArtPicker.rank2": "2nd choice", + "editor.altArtPicker.rank3": "3rd choice", + "editor.altArtPicker.suffix1": "st", + "editor.altArtPicker.suffix2": "nd", + "editor.altArtPicker.suffix3": "rd", + "editor.altArtPicker.choiceWord": "choice", + "editor.altArtPicker.unusedMany": "{$rank}: not used with {$n} copies", + "editor.altArtPicker.unusedOne": "{$rank}: not used with 1 copy", + "editor.altArtPicker.art": "{$text}: choose its rank", + "editor.altArtPicker.menu": "Rank of {$label}", + "editor.altArtPicker.ownedMany": "{$n} owned", + "editor.altArtPicker.ownedOne": "1 owned", + "editor.altArtPicker.summaryNone": "This deck has no copy of this card yet.", + "editor.altArtPicker.summaryOwn": "This deck keeps its own illustrations: “Default alt arts” applies your choices to it.", + "editor.altArtPicker.summaryOne": "With 1 copy, the deck takes the 1st choice.", + "editor.altArtPicker.summaryTwo": "With 2 copies, the deck takes the 1st and 2nd choices.", + "editor.altArtPicker.summaryAll": "With {$n} copies, the deck takes all three choices.", "editor.altArtPicker.available": "Available illustrations", "editor.altArtPicker.settings": "Edit my default alt arts", "editor.altArtPicker.defaults": "Default alt arts", "editor.altArtPicker.close": "Close", "editor.altArtPicker.unlimited": "Unlimited", "editor.altArtPicker.notOwned": "Not owned", - "editor.altArtPicker.placedMany": "{$placed} / {$owned} placed", - "editor.altArtPicker.placedOne": "{$placed} / {$owned} placed", "editor.altArtPicker.copiesMany": "{$n} copies in this deck", "editor.altArtPicker.copiesOne": "1 copy in this deck", "editor.altArtDefaults.open": "Apply my default alt arts", diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index fa8fa871..6b5c7311 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -1156,45 +1156,41 @@ test.describe('ReBuilder in the shell · deck page', () => { await evidence(page, testInfo, '26-unique-art'); }); - test('changes the illustrations of a card’s copies with its brush, within the copies owned', { tag: '@mobile' }, async ({ page }, testInfo) => { + /** The default alt arts saved by the brush (`PUT /api/alt-arts/preferences`), answered without the service. */ + async function routePreferences(page: Page): Promise { + const saved: string[][] = []; + await page.route('**/api/alt-arts/preferences', async (route) => { + saved.push((route.request().postDataJSON() as { slotReferences: string[] }).slotReferences); + await route.fulfill({ status: 204 }); + }); + return saved; + } + + test('sets the choices of a card’s illustrations with its brush, the deck’s copies taking them', { tag: '@mobile' }, async ({ page }, testInfo) => { const kelon = await routeAltArts(page, 1, [0, 0, 0]); + const preferences = await routePreferences(page); await login(page, 'alice', `${DECKS}?lang=fr`); - const id = await createServerDeck(page, `E2E brush ${Date.now()}`, ['ALT_CORE_B_AX_04_C'], 3); + const id = await createServerDeck(page, `E2E brush ${Date.now()}`, ['ALT_CORE_B_AX_04_C'], 2); await page.goto(`${EDITOR(id)}&view=apercu`); await page.getByRole('button', { name: 'Choisir les illustrations de Élémentaire de Kélon' }).click(); const dialog = page.getByRole('dialog', { name: 'Élémentaire de Kélon' }); - const copies = dialog.getByRole('list', { name: 'Exemplaires' }).getByRole('listitem'); - const copy = (n: number) => dialog.getByRole('button', { name: `Exemplaire ${n} : le libérer` }); - const free = copies.getByText('Libre', { exact: true }); - // Three copies on their default alt art (the plain print here), shown as chosen. - await expect(copies).toHaveCount(3); - await expect(copy(1)).toBeVisible(); - await expect(free).toHaveCount(0); - // A touched copy is free; a touched illustration goes on it: the deck is saved with it. - await copy(1).click(); - await expect(free).toHaveCount(1); - let saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 1 && c.get(kelon.base) === 2); - await dialog.getByRole('button', { name: /^Illustration 2,/ }).click(); + // The plain print holds the three choices (the defaults); the deck uses two. + await expect(dialog.getByRole('button', { name: 'Illustration 1, 1er choix, 2e choix, 3e choix : choisir son rang' })).toBeVisible(); + await expect(dialog.getByText('Avec 2 exemplaires, le deck prend les 1er et 2e choix.')).toBeVisible(); + // The alt art as 1st choice: saved as a default alt art, the deck's copy 1 takes it. + const saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 1 && c.get(kelon.base) === 1); + await dialog.getByRole('button', { name: 'Illustration 2 : choisir son rang' }).click(); + await page.getByRole('menuitemradio', { name: '1er choix' }).click(); await saved; - await expect(free).toHaveCount(0); - // The only alt art owned is placed: it waits. - await expect(dialog.getByRole('button', { name: /^Illustration 2,/ })).toHaveAttribute('aria-disabled', 'true'); + expect(preferences).toEqual([[kelon.alt(), kelon.base, kelon.base]]); + await expect(dialog.getByRole('button', { name: 'Illustration 2, 1er choix : choisir son rang' })).toBeVisible(); + await expect(dialog.getByRole('listitem').filter({ hasText: 'Exemplaire 1' })).toContainText('1er choix · Illustration 2'); await evidence(page, testInfo, '30-alt-art-brush'); - // Freed, the copy keeps its default alt art in the deck. - saved = savedCards(page, id, (c) => !c.has(kelon.alt()) && c.get(kelon.base) === 3); - await copy(1).click(); - await saved; - await expect(free).toHaveCount(1); - // An illustration dragged onto a copy goes on that copy. - saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 1 && c.get(kelon.base) === 2); - const from = (await dialog.getByRole('button', { name: /^Illustration 2,/ }).boundingBox())!; - const to = (await copies.nth(2).locator('.slot-drop').boundingBox())!; - await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2); - await page.mouse.down(); - await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 }); - await page.mouse.up(); - await saved; - await expect(free).toHaveCount(1); + // Its only copy owned holds the 1st choice: the other choices cannot take it. + await dialog.getByRole('button', { name: 'Illustration 2, 1er choix : choisir son rang' }).click(); + await expect(page.getByRole('menuitemradio', { name: '1er choix' })).toHaveAttribute('aria-checked', 'true'); + await expect(page.getByRole('menuitemradio', { name: '2e choix' })).toHaveAttribute('aria-disabled', 'true'); + await evidence(page, testInfo, '30-alt-art-brush-menu'); }); test('takes a card brought to 0 off the deck board', async ({ page, compact }) => { @@ -1214,15 +1210,16 @@ test.describe('ReBuilder in the shell · deck page', () => { test('puts a card’s illustrations on one pile of the deck board, copy 1 in front, or on a pile each', async ({ page, compact }, testInfo) => { test.skip(compact, 'the deck board is from 768 px'); const kelon = await routeAltArts(page, 1, [0, 0, 0]); + await routePreferences(page); await login(page, 'alice', `${DECKS}?lang=fr`); const id = await createServerDeck(page, `E2E stack ${Date.now()}`, ['ALT_CORE_B_AX_04_C'], 3); await page.goto(`${EDITOR(id)}&view=apercu`); const piles = page.locator('app-deck-board ac-card-pile').filter({ has: page.getByRole('group', { name: 'Exemplaires de Élémentaire de Kélon dans le deck' }) }); - // The alt art on copy 1 (the brush): the deck has two prints of the card. + // The alt art as 1st choice (the brush): the deck has two prints of the card. await page.getByRole('button', { name: 'Choisir les illustrations de Élémentaire de Kélon' }).click(); - await page.getByRole('dialog').getByRole('button', { name: 'Exemplaire 1 : le libérer' }).click(); const saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 1 && c.get(kelon.base) === 2); - await page.getByRole('dialog').getByRole('button', { name: /^Illustration 2,/ }).click(); + await page.getByRole('dialog').getByRole('button', { name: 'Illustration 2 : choisir son rang' }).click(); + await page.getByRole('menuitemradio', { name: '1er choix' }).click(); await saved; await page.keyboard.press('Escape'); // One pile of 3, copy 1 in front. From fafdfadaf940c3d06f26449377fd5222599fa5f9 Mon Sep 17 00:00:00 2001 From: Yutsa Date: Fri, 9 Oct 2026 22:28:03 +0200 Subject: [PATCH 09/38] =?UTF-8?q?Re:Builder=20brush:=20=C2=AB=20Ce=20que?= =?UTF-8?q?=20le=20deck=20utilise=20=C2=BB=20shows=20the=20copies=20and=20?= =?UTF-8?q?its=20sentence,=20without=20the=20list=20of=20copies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../alt-art-picker/alt-art-picker.overlay.html | 14 ++------------ .../alt-art-picker/alt-art-picker.overlay.scss | 16 ---------------- .../alt-art-picker/alt-art-picker.overlay.ts | 16 ++-------------- .../rebuilder/app/src/locale/messages.en.json | 1 - plugins/rebuilder/e2e/rebuilder.spec.ts | 2 +- 5 files changed, 5 insertions(+), 44 deletions(-) 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 index eed29099..a75f1e4f 100644 --- 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 @@ -44,22 +44,12 @@

Ce que le deck utilise

@if (preview().length) { }

{{ summary() }}

- @if (preview().length) { -
    - @for (p of preview(); track $index) { -
  1. - {{ p.copy }} - {{ p.use }} -
  2. - } -
- }
-

+

-
+
}
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 index cd577ffe..8ee5174b 100644 --- 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 @@ -109,85 +109,11 @@ ul { font: var(--ac-font-caption); color: var(--ac-color-text-muted); } -/* - * The cards' keys: the 1st card on the left, the 2nd in the middle, the 3rd on the right. A key up (a thick bottom - * edge, faded) is a card this illustration can take; a key pressed down (filled) is a card that takes it. - */ +/* The cards' keys (ac-keys, design system): the 1st card on the left, the 2nd in the middle, the 3rd on the right. */ .keys { - --key-edge: 4px; align-self: stretch; - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: var(--ac-space-1-5); margin-bottom: var(--ac-space-2); } -.key { - box-sizing: border-box; - height: var(--ac-control-lg); - min-width: 0; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: var(--ac-space-0-5); - padding: 0; - border: 1.5px solid var(--ac-color-border-control); - border-bottom-width: var(--key-edge); - border-radius: var(--ac-radius-control); - background: var(--ac-color-surface); - color: var(--ac-color-text-2); - font-family: var(--ac-font-family); - opacity: 0.6; - cursor: pointer; - transition: - opacity var(--ac-duration-fast), - color var(--ac-duration-fast), - border-color var(--ac-duration-fast), - transform var(--ac-duration-fast); -} -.key[aria-pressed='true'] { - height: calc(var(--ac-control-lg) - var(--key-edge) + 1.5px); - margin-top: calc(var(--key-edge) - 1.5px); - border-color: var(--ac-color-primary-strong); - border-bottom-width: 1.5px; - 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; -} -.key:not([aria-pressed='true']):not(:disabled):active { - transform: translateY(2px); -} -.key:disabled { - opacity: 0.3; - cursor: not-allowed; -} -.key:focus-visible { - outline: 2px solid var(--ac-color-focus); - outline-offset: 2px; - opacity: 1; -} -@media (hover: hover) { - .key:not([aria-pressed='true']):not(:disabled):hover { - border-color: var(--ac-color-primary); - color: var(--ac-color-primary); - opacity: 1; - transform: translateY(-1px); - } -} -.key-num { - font-size: var(--ac-font-size-body-strong); - font-weight: 800; - line-height: 1; -} -.key-num sup { - font-size: var(--ac-font-size-micro); - line-height: 0; -} -.key-word { - font: var(--ac-font-micro); -} .keys-hint { margin-top: calc(-1 * var(--ac-space-2)); padding-left: calc(var(--ac-space-6) + var(--ac-space-2)); From 68e09461790a36bc5077247d1edf88ea0ba6a762 Mon Sep 17 00:00:00 2001 From: Yutsa Date: Tue, 13 Oct 2026 17:31:24 +0200 Subject: [PATCH 24/38] Alt-art keys: review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re:Builder brush: a pressed key is never disabled (it stayed faded when the copies owned no longer covered the deck's choice). - Token key named by its text (« Choisir » / « Choisie »). - The fan of the default alt arts page is an image named after what each card takes. - One CDN URL helper (the widget's), fan images made once. - e2e: a refused choice puts the keys and the fan back and shows why. Co-Authored-By: Claude Opus 5.5 --- plugins/ownership/e2e/alt-arts.spec.ts | 27 +++++++++++++--- plugins/ownership/js/alt-art-widget.js | 23 ++++++++------ plugins/ownership/js/alt-arts.js | 31 ++++++++++--------- .../alt-art-picker/alt-art-picker.overlay.ts | 2 +- 4 files changed, 53 insertions(+), 30 deletions(-) diff --git a/plugins/ownership/e2e/alt-arts.spec.ts b/plugins/ownership/e2e/alt-arts.spec.ts index 096a6c36..a816da6e 100644 --- a/plugins/ownership/e2e/alt-arts.spec.ts +++ b/plugins/ownership/e2e/alt-arts.spec.ts @@ -6,9 +6,9 @@ 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: Élémentaire de Kélon (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. + * (`/papi/ownership/alt-art-set-preference`) are kept here and answered without the service (204, or `answer`). */ -async function routeAltArts(page: Page): Promise { +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: { @@ -36,7 +36,7 @@ async function routeAltArts(page: Page): Promise { 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({ status: 204 }); + await route.fulfill(answer); }); return saved; } @@ -58,15 +58,32 @@ test('chooses the default alt arts with the keys above each illustration', { tag await expect(key(2, '1ère carte')).toHaveAttribute('aria-pressed', 'true'); await expect(key(1, '1ère carte')).toHaveAttribute('aria-pressed', 'false'); await expect(kelon.locator('.own-aa-fan img').first()).toHaveAttribute('src', new RegExp(KELON.alt)); + await expect(kelon.getByRole('img', { name: '1ère carte : Illustration 2, 2ème carte : Illustration 1, 3ème carte : Illustration 1' })).toBeVisible(); // 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(); - await scarabot.getByRole('button', { name: 'Choisir Illustration 2' }).click(); + 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(scarabot.getByRole('button', { name: 'Choisir Illustration 2' })).toHaveText('Choisie'); + await expect(token).toHaveAccessibleName('Choisie'); + await expect(token).toHaveAttribute('aria-pressed', 'true'); await kelon.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: KELON.alt, requested: 2, owned: 1 }] }); + await login(page, 'alice', '/pages/ownership-alt-arts?lang=fr'); + const kelon = page.getByRole('article').filter({ has: page.getByRole('heading', { name: 'Élémentaire de Kélon' }) }); + const key = (n: number, card: string) => kelon.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([KELON.alt, KELON.base, KELON.base]); + // Refused: the 1st card takes the plain print again, the fan with it, and the reason is shown. + await expect(kelon.getByRole('alert')).toContainText(`${KELON.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(kelon.locator('.own-aa-fan img').first()).toHaveAttribute('src', new RegExp(KELON.base)); +}); diff --git a/plugins/ownership/js/alt-art-widget.js b/plugins/ownership/js/alt-art-widget.js index 24b89a7b..863143f8 100644 --- a/plugins/ownership/js/alt-art-widget.js +++ b/plugins/ownership/js/alt-art-widget.js @@ -46,19 +46,19 @@ 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', + unlimited: 'Illimitée', notOwned: 'Non possédée', ownedOne: '1 possédée', ownedMany: '%s possédées', colon: ' : ', }, 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', + unlimited: 'Unlimited', notOwned: 'Not owned', ownedOne: '1 owned', ownedMany: '%s owned', colon: ': ', }, }; 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)?}. + // card. cfg = {cdnUrl, lang, setPreferenceUrl, csrfToken, txt:{saveError}, onChange(references, description)?}. // Returns {el, destroy()}. function createFamilyRow(family, optData, cfg) { const groupKey = family.familyId + ':' + family.faction + ':' + family.rarity; @@ -97,14 +97,14 @@ : opt.ownedQuantity === 0 ? txt.notOwned : opt.ownedQuantity === 1 ? txt.ownedOne : txt.ownedMany.replace('%s', opt.ownedQuantity); - const keyButton = (opt, index, label) => { + const keyButton = (opt, index) => { const key = document.createElement('button'); key.type = 'button'; key.className = 'ac-key ac-key--sm'; key.dataset.ref = opt.reference; key.dataset.slot = String(index); if (single) { - key.setAttribute('aria-label', txt.choose + ' ' + label); + // 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); @@ -125,7 +125,7 @@ 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, label))); + slots.forEach((s, index) => keys.appendChild(keyButton(opt, index))); print.appendChild(keys); const img = document.createElement('img'); @@ -148,6 +148,11 @@ }); 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 the cards take, in words (« 1ère carte : Illustration 2, … »): the text of what a host draws of them. + const describe = () => single ? illustrationOf(slots[0].reference) + : slots.map((s, i) => (txt.cards[i] || '') + txt.colon + illustrationOf(s.reference)).join(', '); // Each key pressed when its card takes the key's illustration, disabled when it cannot take it. const render = () => { @@ -159,7 +164,7 @@ 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)); + if (cfg.onChange) cfg.onChange(slots.map((s) => s.reference), describe()); }; render(); @@ -228,7 +233,7 @@ // A key: its card takes its illustration. const onClick = (e) => { const key = e.target.closest('.ac-key'); - if (!key || key.disabled) return; + if (!key) return; const slot = slots[parseInt(key.dataset.slot, 10)]; if (!slot || slot.reference === key.dataset.ref) return; slot.reference = key.dataset.ref; @@ -243,5 +248,5 @@ 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 6328f3b6..6d94ab13 100644 --- a/plugins/ownership/js/alt-arts.js +++ b/plugins/ownership/js/alt-arts.js @@ -166,11 +166,6 @@ // ---- Rendering ---- const labelOf = (map, code) => (cfg.labels && cfg.labels[map] && cfg.labels[map][code]) || code; - const cdnUrl = (ref) => { - const p = ref.split('_'); - return cfg.cdnUrl + '/cards/' + cfg.lang + '/' + (p[1] || '') + '/' + ref + '.webp'; - }; - // One family: its name and what decks take on the left (its cards fanned out, the 1st on the left), its // illustrations and their keys on the right. const renderFamilyRow = (family) => { @@ -197,20 +192,26 @@ : t('usesNew', 'A card added to a deck takes'); const fan = document.createElement('div'); fan.className = 'own-aa-fan'; - fan.setAttribute('aria-hidden', 'true'); + fan.setAttribute('role', 'img'); info.append(name, meta, uses, fan); row.appendChild(info); - const drawFan = (references) => { + // One image a card, made once; a change only swaps their sources. + const drawFan = (references, description) => { + fan.setAttribute('aria-label', description); fan.style.setProperty('--n', references.length); - fan.replaceChildren(...references.map((ref, i) => { - const img = document.createElement('img'); - img.src = cdnUrl(ref); - img.alt = ''; - img.loading = 'lazy'; - img.style.setProperty('--i', i); - return img; - })); + references.forEach((ref, i) => { + let img = fan.children[i]; + if (!img) { + img = document.createElement('img'); + img.alt = ''; + img.loading = 'lazy'; + img.style.setProperty('--i', i); + fan.appendChild(img); + } + 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 }); 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 index 9b3e4f4b..3ada993c 100644 --- 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 @@ -73,7 +73,7 @@ export class AltArtPickerOverlay { suffix: cardSuffix(k), label: cardLabel(k), pressed: r === o.reference, - disabled: !canRank(choice, ranks, k, o.reference), + disabled: r !== o.reference && !canRank(choice, ranks, k, o.reference), })), }; }); From fb69b8bf18313c28098496df8e69291cdfdcda62 Mon Sep 17 00:00:00 2001 From: Yutsa Date: Tue, 13 Oct 2026 17:32:03 +0200 Subject: [PATCH 25/38] =?UTF-8?q?Default=20alt=20arts=20e2e:=20F=C3=A9e=20?= =?UTF-8?q?Clochette=20(CORE=20and=20MUSUBI=20prints=20differ)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ownership/e2e/alt-arts.spec.ts | 36 +++++++++++++------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/plugins/ownership/e2e/alt-arts.spec.ts b/plugins/ownership/e2e/alt-arts.spec.ts index a816da6e..32b10513 100644 --- a/plugins/ownership/e2e/alt-arts.spec.ts +++ b/plugins/ownership/e2e/alt-arts.spec.ts @@ -1,10 +1,10 @@ import { evidence, expect, login, test, type Page } from '../../../tests/e2e/fixtures'; -const KELON = { base: 'ALT_CORE_B_AX_04_C', alt: 'ALT_COREKS_B_AX_04_C' }; +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: Élémentaire de Kélon (the plain print and + * 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`). */ @@ -22,13 +22,13 @@ async function routeAltArts(page: Page, answer: { status: number; json?: unknown slots: Array.from({ length: cards }, (_, i) => ({ slotIndex: i + 1, reference: refs.base, isExplicitChoice: false })), }, }); - const kelon = family(1, 'CHARACTER', 'Élémentaire de Kélon', KELON, 3); + 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: [kelon.family, scarabot.family], - options: { '1:AX:C': kelon.options, '2:AX:C': scarabot.options }, + families: [fee.family, scarabot.family], + options: { '1:AX:C': fee.options, '2:AX:C': scarabot.options }, hasMore: false, }, }), @@ -44,21 +44,21 @@ async function routeAltArts(page: Page, answer: { status: number; json?: unknown 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 kelon = page.getByRole('article').filter({ has: page.getByRole('heading', { name: 'Élémentaire de Kélon' }) }); - const key = (n: number, card: string) => kelon.getByRole('group', { name: `Cartes de Illustration ${n}` }).getByRole('button', { name: card }); + 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'); } - await expect(kelon.getByText('Une carte ajoutée à un deck prend')).toBeVisible(); + await expect(fee.getByText('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([KELON.alt, KELON.base, KELON.base]); + 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(kelon.locator('.own-aa-fan img').first()).toHaveAttribute('src', new RegExp(KELON.alt)); - await expect(kelon.getByRole('img', { name: '1ère carte : Illustration 2, 2ème carte : Illustration 1, 3ème carte : Illustration 1' })).toBeVisible(); + await expect(fee.locator('.own-aa-fan img').first()).toHaveAttribute('src', new RegExp(FEE.alt)); + await expect(fee.getByRole('img', { name: '1ère carte : Illustration 2, 2ème carte : Illustration 1, 3ème carte : Illustration 1' })).toBeVisible(); // 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(); @@ -70,20 +70,20 @@ test('chooses the default alt arts with the keys above each illustration', { tag await expect.poll(() => saved.at(-1)).toEqual([SCARABOT.alt]); await expect(token).toHaveAccessibleName('Choisie'); await expect(token).toHaveAttribute('aria-pressed', 'true'); - await kelon.scrollIntoViewIfNeeded(); + 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: KELON.alt, requested: 2, owned: 1 }] }); + 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 kelon = page.getByRole('article').filter({ has: page.getByRole('heading', { name: 'Élémentaire de Kélon' }) }); - const key = (n: number, card: string) => kelon.getByRole('group', { name: `Cartes de Illustration ${n}` }).getByRole('button', { name: card }); + 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([KELON.alt, KELON.base, KELON.base]); + 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(kelon.getByRole('alert')).toContainText(`${KELON.alt} (2/1)`); + 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(kelon.locator('.own-aa-fan img').first()).toHaveAttribute('src', new RegExp(KELON.base)); + await expect(fee.locator('.own-aa-fan img').first()).toHaveAttribute('src', new RegExp(FEE.base)); }); From 98e14504716019799784781b82cc525d9e48b395 Mon Sep 17 00:00:00 2001 From: Yutsa Date: Tue, 13 Oct 2026 17:37:11 +0200 Subject: [PATCH 26/38] Default alt arts page: full-size keys, wider illustrations, two a row on a phone Co-Authored-By: Claude Opus 5.5 --- plugins/ownership/assets/style.css | 16 ++++++++++++---- plugins/ownership/js/alt-art-widget.js | 2 +- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/plugins/ownership/assets/style.css b/plugins/ownership/assets/style.css index 784ae513..ca1f0f55 100644 --- a/plugins/ownership/assets/style.css +++ b/plugins/ownership/assets/style.css @@ -404,9 +404,6 @@ .own-aa-family__info { max-width: none; } - .own-aa-strip { - gap: var(--ac-space-4); - } } /* One family's illustrations, each under its keys (js/alt-art-widget.js; also in the card modals). */ @@ -416,7 +413,7 @@ gap: var(--ac-space-5); } .own-aa-print { - width: 148px; + width: 164px; display: flex; flex-direction: column; align-items: center; @@ -453,6 +450,17 @@ font: var(--ac-font-small); color: var(--ac-color-danger); } +/* Two illustrations a row on a phone. */ +@media (max-width: 767.98px) { + .own-aa-strip { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--ac-space-5) var(--ac-space-4); + } + .own-aa-print { + width: auto; + } +} /* Window over the page (the legacy deckbuilder's token illustrations, assets/deckbuilder/token-arts.js) */ .own-aa-modal-overlay { diff --git a/plugins/ownership/js/alt-art-widget.js b/plugins/ownership/js/alt-art-widget.js index 863143f8..ad2361e8 100644 --- a/plugins/ownership/js/alt-art-widget.js +++ b/plugins/ownership/js/alt-art-widget.js @@ -100,7 +100,7 @@ const keyButton = (opt, index) => { const key = document.createElement('button'); key.type = 'button'; - key.className = 'ac-key ac-key--sm'; + key.className = 'ac-key'; key.dataset.ref = opt.reference; key.dataset.slot = String(index); if (single) { From ac58173a5be84956c1082e5d9073ee4c8bae64de Mon Sep 17 00:00:00 2001 From: Yutsa Date: Tue, 13 Oct 2026 17:44:36 +0200 Subject: [PATCH 27/38] Ownership mock: the alt-art search lists every card type, as the service Without a type filter the mock only searched tokens, so the default alt arts page of the local stack showed no character, spell or permanent. It now reads the Common and Rare card groups (Uniques have no alt art) 200 at a time and counts skip / take in families, as AlteredOwnership's /api/alt-arts/search does. Co-Authored-By: Claude Opus 5.5 --- docker/stack/ownership-mock/index.php | 34 ++++++++++++++++++--------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/docker/stack/ownership-mock/index.php b/docker/stack/ownership-mock/index.php index dfac5538..928a4336 100644 --- a/docker/stack/ownership-mock/index.php +++ b/docker/stack/ownership-mock/index.php @@ -167,9 +167,13 @@ function options(array $f, string $user): array { ]; } -/** Families of the chosen types (tokens by default), one page of the cards API per call. */ +/** + * Families matching the filters, as the service: every card type by default (Common and Rare groups: Uniques have no + * alt art), `skip` / `take` counted in families. The cards API is read 200 groups at a time (about 30 of them have an + * alt art), only as far as the page asked for. + */ function search(string $user): array { - $types = array_values(array_filter((array)($_GET['type'] ?? []), 'is_string')) ?: ['TOKEN', 'TOKEN_LANDMARK_PERMANENT', 'TOKEN_MANA']; + $types = array_values(array_filter((array)($_GET['type'] ?? []), 'is_string')); $factions = array_values(array_filter((array)($_GET['faction'] ?? []), 'is_string')); $rarities = array_values(array_filter((array)($_GET['rarity'] ?? []), 'is_string')); // The collection API (cardType[], cardSet[]) fails on several card types, like the real one: its proxy filters them. @@ -177,22 +181,30 @@ function search(string $user): array { $name = mb_strtolower(trim((string)($_GET['name'] ?? ''))); $locale = (string)($_GET['locale'] ?? 'fr'); $take = max(1, min(100, (int)($_GET['take'] ?? 25))); - $page = intdiv(max(0, (int)($_GET['skip'] ?? 0)), $take) + 1; - $q = 'itemsPerPage=' . $take . '&page=' . $page; + $skip = max(0, (int)($_GET['skip'] ?? 0)); + $q = 'itemsPerPage=200&rarity[]=COMMON&rarity[]=RARE'; foreach ($types as $t) $q .= '&cardType[]=' . rawurlencode($t); foreach ($factions as $fc) $q .= '&faction[]=' . rawurlencode($fc); - $res = cardsApi('/api/card_groups?' . $q); + $matched = []; + $more = true; + for ($page = 1; $more && count($matched) <= $skip + $take; $page++) { + $res = cardsApi('/api/card_groups?' . $q . '&page=' . $page); + foreach ($res['member'] ?? [] as $g) { + $f = toFamily($g); + if (!$f || ($rarities && !in_array($f['rarity'], $rarities, true))) continue; + $label = is_array($f['name']) ? ($f['name'][$locale] ?? $f['name']['en'] ?? reset($f['name'])) : $f['name']; + if ($name !== '' && !str_contains(mb_strtolower((string)$label), $name)) continue; + $matched[] = [$f, $label]; + } + $more = $page * 200 < (int)($res['totalItems'] ?? 0); + } $families = []; $options = []; - foreach ($res['member'] ?? [] as $g) { - $f = toFamily($g); - if (!$f || ($rarities && !in_array($f['rarity'], $rarities, true))) continue; - $label = is_array($f['name']) ? ($f['name'][$locale] ?? $f['name']['en'] ?? reset($f['name'])) : $f['name']; - if ($name !== '' && !str_contains(mb_strtolower((string)$label), $name)) continue; + foreach (array_slice($matched, $skip, $take) as [$f, $label]) { $families[] = key3($f) + ['cardType' => $f['cardType'], 'name' => $label, 'reference' => $f['prints'][0]['reference']]; $options[] = options($f, $user); } - return ['families' => $families, 'options' => $options, 'hasMore' => $page * $take < (int)($res['totalItems'] ?? 0)]; + return ['families' => $families, 'options' => $options, 'hasMore' => $more || count($matched) > $skip + $take]; } /** From bf4636507a2ee74833e750a9140030948f5b0b72 Mon Sep 17 00:00:00 2001 From: Yutsa Date: Tue, 13 Oct 2026 17:49:42 +0200 Subject: [PATCH 28/38] Default alt arts page: larger previews, a card opens large on a click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fan of what decks take is larger, a token's or a hero's single card much larger, and the illustrations under the keys are 200 px wide. A card of the fan or an illustration opens in core-altered-cards' card lightbox, as on the cards pages (tilt, « Accéder au détail »), without its alt-art widget since the keys of the page already choose. Co-Authored-By: Claude Opus 5.5 --- plugins/ownership/assets/style.css | 60 ++++++++++++++++++++++---- plugins/ownership/e2e/alt-arts.spec.ts | 14 +++++- plugins/ownership/js/alt-art-widget.js | 27 +++++++++--- plugins/ownership/js/alt-arts.js | 32 +++++++++----- plugins/ownership/pages/alt-arts.php | 12 ++++++ 5 files changed, 119 insertions(+), 26 deletions(-) diff --git a/plugins/ownership/assets/style.css b/plugins/ownership/assets/style.css index ca1f0f55..04a529e1 100644 --- a/plugins/ownership/assets/style.css +++ b/plugins/ownership/assets/style.css @@ -373,25 +373,50 @@ 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. */ +/* 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: 72px; - --step: 46px; + --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 img { +.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); - aspect-ratio: 5 / 7; - object-fit: cover; + padding: 0; + border: 0; border-radius: var(--ac-radius-sm); - box-shadow: var(--ac-shadow-card); + 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; @@ -413,7 +438,7 @@ gap: var(--ac-space-5); } .own-aa-print { - width: 164px; + width: 200px; display: flex; flex-direction: column; align-items: center; @@ -432,6 +457,25 @@ object-fit: cover; border-radius: var(--ac-radius-md); background: var(--ac-color-track); + transition: transform var(--ac-duration-fast); +} +.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-print__zoom:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: 2px; +} +@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; diff --git a/plugins/ownership/e2e/alt-arts.spec.ts b/plugins/ownership/e2e/alt-arts.spec.ts index 32b10513..cb92e381 100644 --- a/plugins/ownership/e2e/alt-arts.spec.ts +++ b/plugins/ownership/e2e/alt-arts.spec.ts @@ -58,7 +58,19 @@ test('chooses the default alt arts with the keys above each illustration', { tag 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('img', { name: '1ère carte : Illustration 2, 2ème carte : Illustration 1, 3ème carte : Illustration 1' })).toBeVisible(); + 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(); diff --git a/plugins/ownership/js/alt-art-widget.js b/plugins/ownership/js/alt-art-widget.js index ad2361e8..f617adea 100644 --- a/plugins/ownership/js/alt-art-widget.js +++ b/plugins/ownership/js/alt-art-widget.js @@ -46,19 +46,19 @@ 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: ' : ', + unlimited: 'Illimitée', notOwned: 'Non possédée', ownedOne: '1 possédée', ownedMany: '%s possédées', colon: ' : ', zoom: 'Agrandir %s', }, 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: ': ', + unlimited: 'Unlimited', notOwned: 'Not owned', ownedOne: '1 owned', ownedMany: '%s owned', colon: ': ', zoom: 'Enlarge %s', }, }; 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, description)?}. + // 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; @@ -133,7 +133,18 @@ img.src = cdnUrl(cfg, opt.reference); img.loading = 'lazy'; img.alt = ''; - print.appendChild(img); + // 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'; @@ -150,9 +161,11 @@ 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 the cards take, in words (« 1ère carte : Illustration 2, … »): the text of what a host draws of them. - const describe = () => single ? illustrationOf(slots[0].reference) - : slots.map((s, i) => (txt.cards[i] || '') + txt.colon + illustrationOf(s.reference)).join(', '); + // 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 = () => { diff --git a/plugins/ownership/js/alt-arts.js b/plugins/ownership/js/alt-arts.js index 6d94ab13..79a39138 100644 --- a/plugins/ownership/js/alt-arts.js +++ b/plugins/ownership/js/alt-arts.js @@ -192,29 +192,41 @@ : t('usesNew', 'A card added to a deck takes'); const fan = document.createElement('div'); fan.className = 'own-aa-fan'; - fan.setAttribute('role', 'img'); + if (optData.slots.length === 1) fan.classList.add('own-aa-fan--single'); info.append(name, meta, uses, fan); row.appendChild(info); - // One image a card, made once; a change only swaps their sources. - const drawFan = (references, description) => { - fan.setAttribute('aria-label', description); + // 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 img = fan.children[i]; - if (!img) { - img = document.createElement('img'); + 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'; - img.style.setProperty('--i', i); - fan.appendChild(img); + 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 }); + const widgetRow = window.OWN_ALT_ART_WIDGET.createFamilyRow(family, optData, { ...widgetCfg, onChange: drawFan, onZoom: zoom }); row.appendChild(widgetRow.el); return row; }; diff --git a/plugins/ownership/pages/alt-arts.php b/plugins/ownership/pages/alt-arts.php index 939dbc57..5abb115e 100644 --- a/plugins/ownership/pages/alt-arts.php +++ b/plugins/ownership/pages/alt-arts.php @@ -34,6 +34,7 @@ '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', @@ -71,6 +72,7 @@ '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', @@ -339,6 +341,16 @@ })(); + true, 'altArtsUrl' => '']; + include $cacDir . '/pages/_card-zoom-modal.php'; + } + ?> +
From bc1e9dbfc3a9b0f78b3980e22d7b0c81f1d2534a Mon Sep 17 00:00:00 2001 From: Yutsa Date: Tue, 13 Oct 2026 17:53:20 +0200 Subject: [PATCH 29/38] Keys: a key that can be pressed at full strength, a disabled key flat and faded A key that was off and a disabled key looked alike (60 % and 30 % opacity). A key that can be pressed now has its text in --ac-color-text-2 and a darker bottom edge at full opacity; a disabled key loses its edge and fill, in --ac-color-text-disabled. Co-Authored-By: Claude Opus 5.5 --- design-system/css/components/key.css | 15 ++++++++++----- design-system/docs/components/key.md | 11 ++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/design-system/css/components/key.css b/design-system/css/components/key.css index 3ce53da0..6b3b4630 100644 --- a/design-system/css/components/key.css +++ b/design-system/css/components/key.css @@ -1,5 +1,6 @@ /* - * Keys — a row of toggle buttons drawn as keys: raised and faded when off, pressed down and filled when on. + * 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. *
* } @else if (altArts.tokensChoice() === null) { - - + } @if (altArts.canChooseForDeck()) { } @else if (altArts.defaultsChoice() === null) { - + }