Cards.js

替换BGA agricola游戏的语言文件

このスクリプトは単体で利用できません。右のようなメタデータを含むスクリプトから、ライブラリとして読み込まれます: // @require https://update.greasyfork.org/scripts/581454/1844714/Cardsjs.js

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
define(['dojo', 'dojo/_base/declare'], (dojo, declare) => {
  const MAJOR = 'major';
  const HAND_CARDS = 108;
  const HIGHLIGHT_SHARED_SCORING = 111;
  const ACTION_CARD_DISPLAY = 150;

  return declare('agricola.cards', null, {
    setupPlayerCards() {
      // Create an overlay for card animations
      dojo.place("<div id='card-overlay'></div>", 'ebd-body');
      dojo.connect($('card-overlay'), 'click', () => this.zoomOffCard());

      this.setupMajorsImprovements();
      this.setupHandModal();
      this.updatePlayerCards();
      this.updateHandContainer();
      if (this.isActionCardDisplayCentral()) {
        this.moveActionCardsToHolder(true);
      }
      this.setupCardRulingsModal();

      if (!this.isSpectator) {
        this.updateHandCards();

        //        ['hand-container-minor', 'hand-container-occupation'].forEach((container) => {
        ['hand-container'].forEach((container) => {
          sortable('#' + container, {
            handle: '.card-icon',
            forcePlaceholderSize: true,
            placeholderClass: 'card-placeholder',
          });

          $(container).addEventListener('sortstart', (e) => {
            let cardId = e.detail.item.getAttribute('data-id');
            this.closeTooltip(cardId);
            this._dragndropMode = true;
          });

          $(container).addEventListener('sortstop', (e) => {
            this._dragndropMode = false;
          });

          $(container).addEventListener('sortupdate', (e) => {
            let ids = e.detail.destination.items.map((element) => element.getAttribute('data-id'));
            this.takeAction('actOrderCards', { cardIds: JSON.stringify(ids), lock: false }, false);
          });
        });
      }
    },

    // Place Hand container at the correct spot
    updateHandContainer() {
      let isDraft = this._isDraft;
      dojo.toggleClass('draft-wrapper', 'active', isDraft);
      dojo.setStyle('hand-button', 'display', isDraft || this.gamedatas.isBeginner ? 'none' : 'block');

      let container = isDraft ? 'draft-wrapper' : 'popin_showHand_contents';
      if (!isDraft && this.prefs[HAND_CARDS].value != 0) {
        if (this.prefs[HAND_CARDS].value == 2 || this.prefs[HAND_CARDS].value == 3) {
          container = 'alternative-hand-wrapper';
        } else {
          container = dojo.hasClass('player-boards', 'player-boards-right') ? 'player-boards-left-column' : 'player-boards-separator';
        }

        dojo.setStyle('hand-button', 'display', 'none');
      }
      if (!$(container) || !$('hand-container')) return;
      dojo.place('hand-container', container);
      dojo.toggleClass('alternative-hand-wrapper', 'align-left', this.prefs[HAND_CARDS].value == 3);
      let sep = $('player-boards-separator');
      if (sep) sep.style.display = container === 'player-boards-separator' ? '' : 'none';
    },

    /**
     * Move player action cards between player tableaus and the central grouped holder
     */
    moveActionCardsToHolder(toCentral) {
      let hasActionCards = false;

      this.forEachPlayer((player) => {
        let pId = player.id;
        let cardsWrapper = $('cards-wrapper-' + pId);
        let groupContainer = $('action-cards-group-' + pId);
        if (!cardsWrapper || !groupContainer) return;

        if (toCentral) {
          // Move action cards from player tableau to central holder
          let actionCards = Array.from(cardsWrapper.querySelectorAll('.player-card'))
            .filter((el) => {
              let cardData = this._cardStorage[el.getAttribute('data-id')];
              return this.shouldDisplayAsActionCard(cardData);
            });
          actionCards.forEach((el) => dojo.place(el, groupContainer));
          if (actionCards.length > 0) hasActionCards = true;
        } else {
          // Move action cards back to player tableau in correct order
          let actionCards = Array.from(groupContainer.querySelectorAll('.player-card'));
          actionCards.forEach((el) => {
            let order = parseInt(el.getAttribute('data-play-order') || '0');
            let siblings = Array.from(cardsWrapper.querySelectorAll('.player-card'));
            let insertBefore = siblings.find((s) => parseInt(s.getAttribute('data-play-order') || '0') > order);
            if (insertBefore) {
              cardsWrapper.insertBefore(el, insertBefore);
            } else {
              cardsWrapper.appendChild(el);
            }
          });
        }

        // Hide empty groups
        let group = groupContainer.parentNode;
        dojo.toggleClass(group, 'empty', groupContainer.children.length == 0);
      });

      // Also check non-empty groups if switching to central
      if (toCentral) {
        document.querySelectorAll('.action-cards-player-cards').forEach((gc) => {
          if (gc.children.length > 0) hasActionCards = true;
        });
      }

      dojo.style('action-cards-holder', 'display', toCentral && hasActionCards ? 'block' : 'none');
      this.updatePlayerBoardDimensions();
      this.updateActionCardsHolderMinHeight();
    },

    updateActionCardsHolderVisibility() {
      if (!this.isActionCardDisplayCentral()) {
        dojo.style('action-cards-holder', 'display', 'none');
        this.updateActionCardsHolderMinHeight();
        return;
      }
      let hasCards = false;
      document.querySelectorAll('.action-cards-player-cards').forEach((gc) => {
        let groupHasCards = gc.children.length > 0;
        dojo.toggleClass(gc.parentNode, 'empty', !groupHasCards);
        if (groupHasCards) hasCards = true;
      });
      dojo.style('action-cards-holder', 'display', hasCards ? 'block' : 'none');
      this.updateActionCardsHolderMinHeight();
    },

    // When left column is position:absolute (player-boards-right mode),
    // the holder doesn't contribute to flow height. Compensate via min-height.
    updateActionCardsHolderMinHeight() {
      let holder = $('action-cards-holder');
      let wrapper = $('position-wrapper');
      if (!holder || !wrapper) return;
      if (!dojo.hasClass('player-boards', 'player-boards-right') || holder.style.display == 'none') {
        wrapper.style.minHeight = '';
        return;
      }
      let holderRect = holder.getBoundingClientRect();
      let wrapperRect = wrapper.getBoundingClientRect();
      let neededHeight = holderRect.bottom - wrapperRect.top;
      wrapper.style.minHeight = neededHeight + 'px';
    },

    updatePlayerCards() {
      // This function is refreshUI compatible
      let cardIds = this.gamedatas.playerCards.map((card) => {
        this.loadSaveCard(card);
        // Create the card if needed
        if (!$(card.id)) {
          this.addCard(card);
        }
        // Move the card if not correct container
        let o = $(card.id);
        let container = this.getCardContainer(card);
        if (o.parentNode != $(container)) {
          dojo.place(o, container);
          dojo.toggleClass(o, 'mini', card.mini || card.location == 'inPlay');
        }
        if (card.location == 'inPlay') {
          o.setAttribute('data-play-order', card.state || 0);
        }

        // Sync DOM .stats with fresh gamedatas so turn restart resets the tooltip.
        // notif_updateCardStats writes directly to o.stats during play; refreshUI must reset it.
        o.stats = card.stats;
        o.usedText = card.usedText;

        // Refresh bonusVP
        let counter = document.querySelector('#' + card.id + ' .card-bonus-vp-counter');
        counter.innerHTML = card.bonusVp;
        this.refreshCardTooltipBonusVp(card.id, card.bonusVp);

        // Set up or refresh infobox
        if (card.infobox == null) {
          this.removeInfobox(card);
        }
        else {
          if (dojo.query('.infobox', card.id).length == 0) {
            this.placeInfobox(card);
          } else {
            this.updateInfobox(card);
          }
        }

        // Mark usable harvest cards
        if (card.marked) {
          this.markUsableExchange(card.id);
        }

        // Highlight shared scoring cards
        this.maybeAddSharedScoringHighlight(o, card);

        return card.id;
      });

      // All the cards not in specified list must be destroyed
      document.querySelectorAll('.player-card').forEach((oCard) => {
        if (!cardIds.includes(oCard.getAttribute('data-id'))) {
          dojo.destroy(oCard);
        }
      });

      this.updatePlayerBoardDimensions();
      this.updateActionCardsHolderVisibility();
    },

    updateHandCards() {
      let player = this.gamedatas.players[this.player_id];
      let cards = player.hand ? player.hand : [];
      dojo.empty('hand-container');
      cards.sort((a, b) => a.state - b.state);
      cards.forEach((card) => this.addCard(card));
    },

    /**
     * Create the modal that holds the major improvements
     */
    setupMajorsImprovements() {
      const MAJORS = [
        'Fireplace1',
        'Fireplace2',
        'CookingHearth1',
        'CookingHearth2',
        'Well',
        'ClayOven',
        'StoneOven',
        'Joinery',
        'Pottery',
        'Basket',
      ];

      this._majorsDialog = new customgame.modal('showMajors', {
        class: 'agricola_popin',
        closeIcon: 'fa-times',
        //        openAnimation: true,
        //        openAnimationTarget: 'majors-button',
        contents:
          `
          <div id="majors-container">
            ` +
          MAJORS.map((id) => `<div class="major-holder" id="Major_${id}_holder"></div>`).join('') +
          `
          </div>
        `,
        closeAction: 'hide',
        statusElt: 'majors-button',
        verticalAlign: 'flex-start',
        scale: 0.8,
        breakpoint: 1200,
      });

      this.addCustomTooltip('majors-button', _('Display available major improvements'));
      this.onClick('majors-button', () => this.openMajorsModal(), false);
    },

    /**
     * Open the major improvement modal
     */
    openMajorsModal() {
      this._majorsDialog.show();
    },

    /**
     * Create the modal that holds the cards in hand
     */
    setupHandModal() {
      this._handDialog = new customgame.modal('showHand', {
        class: 'agricola_popin',
        closeIcon: 'fa-times',
        contents: "<div id='hand-container'></div>",
        //          "<div id='hand-container'><div id='hand-container-occupation'></div><div id='hand-container-minor'></div></div>",
        closeAction: 'hide',
        statusElt: 'hand-button',
        verticalAlign: 'flex-start',
        scale: 0.8,
        breakpoint: 1200,
      });

      this.addCustomTooltip('hand-button', _('Display cards in hand'));
      this.onClick('hand-button', () => this.openHandModal(), false);
    },

    /**
     * Open the hand modal
     */
    openHandModal() {
      this._handDialog.show();
    },

    /**
     * Create the modal for card rulings
     */
    setupCardRulingsModal() {
      if ($('popin_showCardRulings_container')) {
        dojo.destroy('popin_showCardRulings_container');
      }

      this._cardRulingsDialog = new customgame.modal('showCardRulings', {
        class: 'agricola_popin',
        closeIcon: 'fa-times',
        closeAction: 'hide',
        verticalAlign: 'center',

        openAnimation: true,
        openAnimationTarget: null,

        title: '',
        contents: `
          <div id="card-rulings-layout">
            <div id="card-rulings-preview"></div>
            <div id="card-rulings-text"></div>
          </div>
        `,
      });
      this.fixModalToViewport('showCardRulings');
    },

    fixModalToViewport(id) {
      const container = $(`popin_${id}_container`);
      const underlay = $(`popin_${id}_underlay`);
      const wrapper = $(`popin_${id}_wrapper`);

      if (!container || !underlay || !wrapper) {
        return;
      }

      container.style.position = 'fixed';
      container.style.left = '0';
      container.style.top = '0';
      container.style.width = '100vw';
      container.style.height = '100vh';

      underlay.style.position = 'fixed';
      underlay.style.left = '0';
      underlay.style.top = '0';
      underlay.style.width = '100vw';
      underlay.style.height = '100vh';

      wrapper.style.position = 'fixed';
      wrapper.style.left = '0';
      wrapper.style.top = '0';
      wrapper.style.width = '100vw';
      wrapper.style.height = '100vh';

      container.style.zIndex = '9999';
      underlay.style.zIndex = '9999';
      wrapper.style.zIndex = '10000';

      // Prevent the close icon from jumping the page to the top
      const close = $(`popin_${id}_close`);
      if (close) {
        close.setAttribute('href', 'javascript:void(0)');
        close.addEventListener(
          'click',
          (e) => {
            e.preventDefault();
          },
          true
        );
      }
    },

    getRulingsCardPreviewNode(card) {
      const c = Object.assign({}, card, { mini: false });

      const tmp = document.createElement('div');
      tmp.innerHTML = this.tplPlayerCard(c, false);

      const node = tmp.firstElementChild;

      // Avoid id collisions with the real card element in DOM
      node.id = 'rulings_' + card.id;
      node.classList.add('rulings-preview');

      // Remove zoom control inside the preview
      const zoom = node.querySelector('.player-card-zoom');
      if (zoom) {
        zoom.remove();
      }

      // Swallow pointer/click events so it never interacts with the game (or closes zoom)
      const swallow = (evt) => {
        evt.preventDefault();
        evt.stopPropagation();
        evt.stopImmediatePropagation();
      };

      ['click', 'mousedown', 'mouseup', 'pointerdown', 'pointerup', 'touchstart', 'touchend'].forEach((t) => {
        node.addEventListener(t, swallow, true);
      });

      return node;
    },

    showCardRulingsModal(card, openAnimationTarget = null) {
      if (!card) return;

      if (!this._cardRulingsDialog) {
        this.setupCardRulingsModal();
      }

      this._cardRulingsDialog.openAnimationTarget = openAnimationTarget;

      // Dark underlay
      const underlay = $('popin_showCardRulings_underlay');
      if (underlay) underlay.style.backgroundColor = '#000';

      // Popin sizing
      const popin = $('popin_showCardRulings');
      if (popin) {
        popin.style.width = 'min(860px, 92vw)';
        popin.style.maxWidth = '860px';
        popin.style.boxSizing = 'border-box';
      }

      // Layout (just the one important alignment)
      const layout = $('card-rulings-layout');
      if (layout) {
        layout.style.display = 'flex';
        layout.style.gap = '18px';
        layout.style.alignItems = 'stretch';
      }

      // Title
      const titleEl = $('popin_showCardRulings_title');
      if (titleEl) titleEl.innerHTML = _(card.name || '');

      // Left: card preview
      const preview = $('card-rulings-preview');
      if (preview) {
        dojo.empty(preview);

        preview.style.setProperty('--agricolaCardWidth', '188px');
        preview.style.setProperty('--agricolaCardHeight', '299px');
        preview.style.setProperty('--agricolaCardScale', '0.8');

        dojo.place(this.getRulingsCardPreviewNode(card), preview);
      }

      // Right: rulings lines (no <ul>/<li>)
      const rulings = Array.isArray(card.rulings) ? card.rulings : [];
      const linesHtml = (rulings.length ? rulings : [''])
        .map((line) => `<div class="agr-rulings__line">${this.formatRulingsLine(line)}</div>`)
        .join('');

      const text = $('card-rulings-text');
      if (text) {
        text.innerHTML = `<div class="agr-rulings">${linesHtml}</div>`;

        const wrap = text.querySelector('.agr-rulings');

        const sync = () => {
          if (!layout || !preview || !wrap) return;

          const isColumn = getComputedStyle(layout).flexDirection === 'column';
          if (isColumn) {
            text.style.height = '';
            wrap.classList.remove('is-centered');
            return;
          }

          // Measure the actual rendered card element (preview container can report 0)
          const cardEl =
            preview.querySelector('.player-card') ||
            preview.querySelector('.card') ||
            preview.firstElementChild;

          const rect = cardEl ? cardEl.getBoundingClientRect() : preview.getBoundingClientRect();
          const ph = rect.height;

          if (ph > 50) {
            text.style.height = `${Math.round(ph)}px`;
          } else {
            text.style.height = '';
          }

          const fits = wrap.scrollHeight <= text.clientHeight + 1;
          wrap.classList.toggle('is-centered', fits);
        };

        requestAnimationFrame(() => requestAnimationFrame(sync));
      }

      this._cardRulingsDialog.show();
    },

    formatRulingsLine(s) {
      let out = _(s);
      out = out.replace(/__([^_]+)__/g, '<span class="action-card-name-reference">$1</span>');
      out = this.formatStringMeeples(out);
      return out;
    },

    getCardById(cardId) {
      const inPlay = (this.gamedatas.playerCards || []).find((c) => c.id === cardId);
      if (inPlay) return inPlay;

      const hand = (this.gamedatas.players?.[this.player_id]?.hand || []).find((c) => c.id === cardId);
      if (hand) return hand;

      return null;
    },

    /**
     * Smarter buffering of card details (to not resend them over notification again)
     */
    loadSaveCard(card) {
      if (card.desc) {
        // Card contains all info => save it in cardStorage
        this._cardStorage[card.id] = card;
      } else {
        // Card is missing information : load it from cardStorage
        if (this._cardStorage[card.id] === undefined) {
          console.error('Missing card informations :', card);
          return;
        }

        // Only static card info — stats/bonusVp/infobox/marked are dynamic and flow
        // directly from server notifications or gamedatas, so never copy them here.
        [
          'actionCard',
          'category',
          'costText',
          'costs',
          'deck',
          'desc',
          'extraVp',
          'fee',
          'field',
          'holder',
          'animalHolder',
          'name',
          'numbering',
          'passing',
          'players',
          'prerequisite',
          'sharedScoring',
          'tooltip',
          'type',
          'usedText',
          'vp',
        ].forEach((info) => (card[info] = this._cardStorage[card.id][info]));
      }
    },

    /**
     * Add a card to its default location
     */
    addCard(card, container = null) {
      this.loadSaveCard(card);
      card.description = this.formatCardDesc(card.desc);
      card.mini = card.mini ? card.mini : card.location == 'inPlay';

      if (container == null) {
        container = this.getCardContainer(card);
      }

      // Some locations should not be rendered (e.g. discardpile)
      if (container === false) {
        return;
      }

      let oCard = this.place('tplPlayerCard', card, container);
      oCard.usedText = card.usedText;
      if (card.location == 'inPlay') {
        oCard.setAttribute('data-play-order', card.state || 0);
      }
      this.addCustomTooltip(card.id, this.tplPlayerCard(card, true));
      if (card.animalHolder) {
        setTimeout(() => {
          let animalsWrapper = oCard.querySelector('.animals-control');
          dojo.connect(animalsWrapper, 'mouseenter', (evt) => {
            evt.stopPropagation();
          });
        }, 1);
      }
      if (card.mini && !oCard.dataset.zoomWired) {
        oCard.dataset.zoomWired = '1';
        oCard.querySelector('.player-card-zoom').addEventListener('click', () => {
          this.zoomOnCard(card.id);
        });
        // On touch, the zoom button is only visible after CSS :hover activates on the first tap.
        // Trigger zoom directly on touchend so it works on the first tap.
        let touchStartX, touchStartY;
        oCard.querySelector('.player-card-resizable').addEventListener('touchstart', (evt) => {
          touchStartX = evt.touches[0].clientX;
          touchStartY = evt.touches[0].clientY;
        }, { passive: true });
        oCard.querySelector('.player-card-resizable').addEventListener('touchend', (evt) => {
          if (touchStartX == null) return;
          const touch = evt.changedTouches[0];
          if (Math.abs(touch.clientX - touchStartX) > 10 || Math.abs(touch.clientY - touchStartY) > 10) return;
          touchStartX = null;
          if (oCard.classList.contains('selectable')) return; // action card: let the synthetic click fire
          evt.preventDefault(); // suppress synthetic click so zoom button click handler doesn't double-fire
          this.zoomOnCard(card.id);
        });
      }

      this.maybeAddCardRulingsIcon(oCard, card);
      this.maybeAddSharedScoringHighlight(oCard, card);

      if (card.pId) {
        this.updatePlayerBoardDimensions(card.pId);
      }
    },

    isSharedScoringHighlightEnabled() {
      if (!this.prefs || !this.prefs[HIGHLIGHT_SHARED_SCORING]) {
        return false;
      }
      return Number(this.prefs[HIGHLIGHT_SHARED_SCORING].value) === 1;
    },

    maybeAddSharedScoringHighlight(oCard, card) {
      let inner = oCard.querySelector('.player-card-inner');
      if (card.sharedScoring && card.location == 'inPlay' && this.isSharedScoringHighlightEnabled()) {
        dojo.addClass(oCard, 'shared-scoring');
        if (!inner.querySelector('.shared-scoring-sash')) {
          let sash = document.createElement('div');
          sash.className = 'shared-scoring-sash';
          sash.innerHTML = '<div class="shared-scoring-icon"></div>';
          inner.appendChild(sash);
        }
      } else {
        dojo.removeClass(oCard, 'shared-scoring');
        if (inner) {
          let sash = inner.querySelector('.shared-scoring-sash');
          if (sash) sash.remove();
        }
      }
    },

    refreshSharedScoringHighlights() {
      dojo.query('.player-card').forEach((node) => {
        let cardId = node.getAttribute('data-id');
        let card = this._cardStorage[cardId];
        if (card) {
          this.maybeAddSharedScoringHighlight(node, card);
        }
      });
    },

    isCardRulingsIconEnabled() {
      const OPTION_CARD_RULINGS_ICON = 110;

      if (!this.prefs || !this.prefs[OPTION_CARD_RULINGS_ICON]) {
        return true;
      }

      return Number(this.prefs[OPTION_CARD_RULINGS_ICON].value) === 1;
    },

    maybeAddCardRulingsIcon(oCard, card) {
      if (!this.isCardRulingsIconEnabled()) {
        return;
      }

      if (!card || !Array.isArray(card.rulings) || card.rulings.length === 0) {
        return;
      }

      const anchor = oCard.querySelector('.card-desc');
      if (!anchor) {
        return;
      }

      if (anchor.querySelector('.card-rulings-icon')) {
        return;
      }

      dojo.addClass(oCard, 'has-rulings');

      const btn = document.createElement('div');
      btn.className = 'card-rulings-icon';
      btn.setAttribute('role', 'button');
      btn.setAttribute('tabindex', '0');
      btn.title = _('Card rulings');
      btn.textContent = '?';

      const open = (evt) => {
        evt.preventDefault();
        evt.stopPropagation();

        const stableAnchor = $(card.id) || btn;

        const doOpen = () => this.showCardRulingsModal(card, stableAnchor);

        if (this._zoomedCard != null) {
          this.zoomOffCardInstant({}).then(() => doOpen());
        } else {
          doOpen();
        }
      };

      btn.addEventListener('click', open);

      // Prevent the card click (zoom) from stealing this click
      btn.addEventListener('mousedown', (evt) => evt.stopPropagation());
      btn.addEventListener('touchstart', (evt) => evt.stopPropagation());
      btn.addEventListener('keydown', (evt) => {
        if (evt.key === 'Enter' || evt.key === ' ') {
          open(evt);
        }
      });

      anchor.appendChild(btn);
    },

    refreshCardRulingsIcons() {
      const OPTION_CARD_RULINGS_ICON = 110;

      const enabled =
        !this.prefs || !this.prefs[OPTION_CARD_RULINGS_ICON]
          ? true
          : Number(this.prefs[OPTION_CARD_RULINGS_ICON].value) === 1;

      dojo.query('.player-card').forEach((node) => {
        // Remove any existing icon and class first
        node.querySelectorAll('.card-rulings-icon').forEach((n) => n.remove());
        dojo.removeClass(node, 'has-rulings');

        if (!enabled) {
          return;
        }

        const cardId =
          node.getAttribute('data-id') ||
          (node.id ? node.id.replace(/^tooltip_/, '') : null);

        if (!cardId) {
          return;
        }

        const card = (typeof this.getCardById === 'function' ? this.getCardById(cardId) : null)
          || (this.gamedatas?.playerCards || []).find((c) => c.id === cardId)
          || null;

        if (!card) {
          return;
        }

        this.maybeAddCardRulingsIcon(node, card);
      });
    },

    refreshCardTooltipBonusVp(cardId, newValue) {
      let content = this.tooltips[cardId].label;
      content = content.replace(
        /<div class='card-bonus-vp-counter'>[0-9]*<\/div>/g,
        `<div class='card-bonus-vp-counter'>${newValue}</div>`,
      );
      this.tooltips[cardId].label = content;
    },

    isActionCardDisplayCentral() {
      return this.prefs[ACTION_CARD_DISPLAY] && this.prefs[ACTION_CARD_DISPLAY].value == 1;
    },

    shouldDisplayAsActionCard(cardData) {
      if (!cardData || !cardData.actionCard) return false;
      // Basket Chair is a private space, not a real action space for workers
      if (cardData.id == 'C22_BasketChair') return false;
      // Studio Boat only acts as an action card in 1-3 player games
      if (cardData.id == 'C39_StudioBoat' && Object.keys(this.gamedatas.players).length >= 4) return false;
      return true;
    },

    getCardContainer(card) {
      if (card.location == 'inPlay') {
        // Action card in central mode => in the grouped holder
        if (this.shouldDisplayAsActionCard(card) && this.isActionCardDisplayCentral()) {
          return 'action-cards-group-' + card.pId;
        }
        // Played card => in front of player
        return 'cards-wrapper-' + card.pId;
      } else if (card.type == MAJOR && card.location == 'board') {
        // Available major => in the major modal
        return card.id + '_holder';
      } else if (card.location == 'hand' || card.location == 'selection') {
        // Card in hand => in the 'hand' modal
        return 'hand-container';
        //        return 'hand-container-' + card.type;
      } else if (card.location == 'draft') {
        return 'draft-container';
      } else if (card.location == 'livingHandOffer' || card.location == 'livingDraft') {
        // Living Hand offers are private: only render for the owning player
        return parseInt(card.pId) === parseInt(this.player_id) ? 'draft-container' : false;
      } else if (card.location == 'discardpile' || card.location == 'phase2') {
        // Don't render on the board
        return false;
      }

      console.error('Trying to get container of a card', card);
      return 'game_play_area';
    },

    /**
     * Template for all "player" cards (Improvements and Occupations)
     */
    tplPlayerCard(card, tooltip = false) {
      let formatStr = (s) => _(s).replace(/__([^_]+)__/g, '<span class="action-card-name-reference">$1</span>');

      let costText = card.costText == '' ? '' : formatStr(card.costText);
      let prerequisite = card.prerequisite == '' ? '' : formatStr(card.prerequisite);
      let description = card.description;
      if (description === undefined) {
        description = this.formatCardDesc(card.desc);
      }
      if (description === undefined || description === null) {
        description = '';
      }

      let subHolders = '';
      if (card.id == 'D75_WoodField' && !tooltip) {
        subHolders = '<div class="subholder" data-x="0"></div><div class="subholder" data-x="-1"></div>';
      }
      if (card.id == 'E80_RockGarden' && !tooltip) {
        subHolders = '<div class="subholder" data-x="0"></div><div class="subholder" data-x="-1"></div><div class="subholder" data-x="-2"></div>';
      }

      let uid = (tooltip ? 'tooltip_' : '') + card.id;
      return (
        `<div id="${uid}" data-id='${card.id}' data-numbering='${card.numbering}'
          class='player-card ${card.type} ${card.mini && !tooltip ? 'mini' : ''} ${card.location == 'selection' ? 'pending' : ''
        } ${card.animalHolder ? 'animalHolder' : ''}'
          data-cook='${card.cook}' data-bread='${card.bread}'
          data-state='${card.state}'>
          <div class='player-card-resizable'>
            <div class='player-card-inner'>
              <div class='card-frame'></div>
              ` +
        (card.passing === true
          ? ''
          : `<div class='card-frame-left-leaves'></div><div class='card-frame-right-leaves'></div>`) +
        `
              <div class='card-icon'></div>
              <div class='card-title'>
                ${_(card.name)}
              </div>
              <div class='card-numbering'>${card.numbering}</div>
              <div class='card-bonus-vp-counter'>${card.bonusVp}</div>
              ` +
        (card.players == undefined ? '' : `<div class='card-players' data-n="${card.players}"></div>`) +
        (card.deck == undefined ? '' : `<div class='card-deck' data-deck="${card.deck}"></div>`) +
        (card.vp != 0 ? `<div class='card-score' data-score="${card.vp}">${card.vp}</div>` : '') +
        (card.extraVp ? `<div class='card-extra-score'></div>` : '') +
        `
              <div class="card-category" data-category="${card.category}"></div>
              <div class="card-cost">
                ` +
        (costText == '' ? '' : `<div class="card-cost-text">${costText}</div>`) +
        `
                ${this.formatCardCost(card)}
              </div>` +
        (prerequisite == ''
          ? ''
          : `<div class="card-prerequisite"><div class="prerequisite-text">${prerequisite}</div></div>`) +
        `
              <div class='card-desc'><div class='card-desc-scroller'>${description}</div></div>
              <div class='card-bottom-left-corner'></div>
              <div class='card-bottom-right-corner'></div>
              ` +
        (card.holder
          ? "<div class='resource-holder farmer-holder resource-holder-update " +
          (card.actionCard ? 'actionCard' : '') +
          `'>${subHolders}</div>`
          : '') +
        `
            </div>
            <div class="player-card-zoom">
              <svg><use href="#zoom-svg" /></svg>
            </div>
          </div>
          ` +
        `
            <div class='player-card-stats'></div>
          ` +
        (card.field ? "<div class='player-card-field-cell'></div>" : '') +
        (!tooltip && card.animalHolder
          ? "<div class='resource-holder resource-holder-update animal-holder' data-n='0'></div>"
          : '') +
        `
        </div>`
      );
    },

    /**
     * Format card cost
     */
    formatCardCost(card) {
      let formatArray = (arr) =>
        Object.keys(arr)
          .map((res) => '<div>' + this.formatStringMeeples(arr[res] + '<' + res.toUpperCase() + '>') + '</div>')
          .join('');

      let formatConditionalCost = (arr) =>
        Object.keys(arr)
          .map((res) => '<div>(' + this.formatStringMeeples(arr[res] + '<' + res.toUpperCase() + '>') + ')</div>')
          .join('');

      if (card.id === 'C40_CanvasSack') {
        card.costs = [{ "grain": 1 }, { "reed": 1 }];
      }

      if (card.id === 'B65_GrainDepot') {
        card.costs = [{ "wood": 2 }, { "clay": 2 }, { "stone": 2 }];
      }

      return (
        (card.fee != null ? formatArray(card.fee) + '<div class="card-cost-fee-separator">+</div>' : '') +
        card.costs.map((cost) => formatArray(cost)).join('<div class="card-cost-separator"></div>') +
        (card.conditionalCost != null ? '<div class="card-cost-conditional">' + formatConditionalCost(card.conditionalCost) + '</div>' : '')
      );
    },

    /**
     * Prompt current player to pick a card
     */
    promptCard(types, cards, callback) {
      if (this.isFastMode()) return;

      // Majors
      if (types.includes('major')) {
        dojo.query('#majors-container .player-card').addClass('unselectable');
        this.addPrimaryActionButton('btnOpenMajorModal', _('Show major improvements'), () => this.openMajorsModal());

        if (types.length == 1) {
          // If only major are prompted, auto open modal
          this.openMajorsModal();
        }
      }

      // Hand
      if (types.includes('minor') || types.includes('occupation')) {
        dojo.query('#hand-container .player-card').addClass('unselectable');
        if (this.prefs[HAND_CARDS].value == 0) {
          this.addPrimaryActionButton('btnOpenHandModal', _('Show hand cards'), () => this.openHandModal());

          if (types.length == 1) {
            // If only one type is prompted, auto open modal
            this.openHandModal();
          }
        }
      }

      // Add event listener
      cards.forEach((cardId) => this.onClick(cardId, () => callback(cardId)));
    },

    /**
     * Prompt current player to pick a card
     */
    promptCardMultiple(types, cards, requiredSelections, callback) {
      let selectedCards = [];

      let select = (cId) => {
        dojo.addClass(cId, 'selected');
      };
      let unselect = (cId) => {
        dojo.removeClass(cId, 'selected');
      };

      let onClickCard = (cId) => {
        // Toggle element
        let i = selectedCards.findIndex((p) => p == cId);
        if (i == -1) {
          select(cId);
          selectedCards.push(cId);
        } else {
          unselect(cId);
          selectedCards.splice(i, 1);
        }

        // Execute callback
        if (selectedCards.length == requiredSelections) {
          if (this._handDialog.isDisplayed()) {
            this._handDialog.hide();
          }
          callback(selectedCards);
        }
      };

      // Hand
      if (types.includes('minor') || types.includes('occupation')) {
        dojo.query('#hand-container .player-card').addClass('unselectable');
        if (this.prefs[HAND_CARDS].value == 0) {
          this.addPrimaryActionButton('btnOpenHandModal', _('Show hand cards'), () => this.openHandModal());

          if (types.length == 1) {
            // If only one type is prompted, auto open modal
            this.openHandModal();
          }
        }
      }

      // Attach event to cards
      cards.forEach((cId) => {
        this.onClick(cId, () => onClickCard(cId));
      });
    },

    computeSlidingAnimationFrom(card, newContainer) {
      let from = 'hand-button';
      if (!$(card.id)) {
        this.addCard(card, newContainer);
        from = 'overall_player_board_' + card.pId;
      } else {
        dojo.place(card.id, newContainer);
        if (card.type == 'major') {
          from = this._majorsDialog.isDisplayed() ? card.id + '_holder' : 'majors-button';
        } else {
          from = this._handDialog.isDisplayed() || this.prefs[HAND_CARDS].value != 0 ? 'hand-container' : 'hand-button';
        }
      }

      this.updatePlayerBoardDimensions();
      return from;
    },

    getCardPreviewWaitTime() {
      const DEFAULT_SPEED = 80;
      const MIN_SPEED = 30;
      const MAX_SPEED = 200;
      const MIN_WAIT_MS = 250;
      const MAX_WAIT_MS = 3000;

      let speed = parseInt(this._cardAnimationSpeed, 10);
      if (!Number.isFinite(speed)) {
        speed = DEFAULT_SPEED;
      }
      speed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, speed));

      const wait = Math.round(80000 / speed);
      return Math.max(MIN_WAIT_MS, Math.min(MAX_WAIT_MS, wait));
    },

    /**
     * Notification when someone bought a card
     */
    notif_buyCard(n) {
      debug('Notif: buying a card', n);
      let card = n.args.card;

      dojo.query('.cards-wrapper .player-card.phantom').removeClass('phantom');

      let duration = 700;
      let waitingTime = this.getCardPreviewWaitTime();

      // Determine target container based on action card display preference
      let targetContainer = this.shouldDisplayAsActionCard(card) && this.isActionCardDisplayCentral()
        ? 'action-cards-group-' + card.pId
        : 'cards-wrapper-' + card.pId;

      // Create the card if needed, and compute initial location of sliding event
      let exists = $(card.id);
      let from = this.computeSlidingAnimationFrom(card, targetContainer);

      // Zoom on it, then zoom off
      if (this.isFastMode() || this.isInstantSpeed()) {
        this.notifqueue.setSynchronousDuration(this.isFastMode() ? 0 : 10);
      } else {
        this.zoomOnCard(card.id, { from, duration })
          .then(() => this.wait(waitingTime))
          .then(() => this.zoomOffCard({ duration }))
          .then(() => this.notifqueue.setSynchronousDuration(10));
      }

      // If the card was already existing, make sure to add event listener for zooming
      // (guarded so we don't stack duplicates on re-buys)
      if (exists) {
        dojo.addClass(card.id, 'mini');
        const existingCard = $(card.id);
        if (!existingCard.dataset.zoomWired) {
          existingCard.dataset.zoomWired = '1';
          existingCard.querySelector('.player-card-zoom').addEventListener('click', () => {
            this.zoomOnCard(card.id);
          });
          let touchStartX, touchStartY;
          existingCard.querySelector('.player-card-resizable').addEventListener('touchstart', (evt) => {
            touchStartX = evt.touches[0].clientX;
            touchStartY = evt.touches[0].clientY;
          }, { passive: true });
          existingCard.querySelector('.player-card-resizable').addEventListener('touchend', (evt) => {
            if (touchStartX == null) return;
            const touch = evt.changedTouches[0];
            if (Math.abs(touch.clientX - touchStartX) > 10 || Math.abs(touch.clientY - touchStartY) > 10) return;
            touchStartX = null;
            if (existingCard.classList.contains('selectable')) return; // action card: let the synthetic click fire
            evt.preventDefault();
            this.zoomOnCard(card.id);
          });
        }
      }

      // Stamp play order for action card toggle ordering
      let oCard = $(card.id);
      if (oCard && card.location == 'inPlay') {
        oCard.setAttribute('data-play-order', card.state || 0);
      }

      // Highlight shared scoring cards
      this.maybeAddSharedScoringHighlight(oCard, card);

      // Update action cards holder visibility
      this.updateActionCardsHolderVisibility();

      // Close major modal if open
      if (this._majorsDialog.isDisplayed()) {
        this._majorsDialog.hide();
      }
      // Close hand modal if open
      if (this._handDialog.isDisplayed()) {
        this._handDialog.hide();
      }

      return null;
    },

    /**
     * Notification when someone bought a card and give it to next player
     */
    notif_buyAndPassCard(n) {
      debug('Notif: buying and passing a card', n);
      let card = n.args.card;
      let receiving = this.player_id == n.args.player_id2;

      let duration = 700;
      let waitingTime = this.getCardPreviewWaitTime();

      // Create the card if needed, and compute initial location of sliding event
      let from = this.computeSlidingAnimationFrom(card, receiving ? 'hand-button' : 'reserve-' + n.args.player_id2);
      if (this.isFastMode() || this.isInstantSpeed()) {
        if (receiving) {
          dojo.place(card.id, 'hand-container');
        } else {
          dojo.destroy(card.id);
        }
        this.notifqueue.setSynchronousDuration(this.isFastMode() ? 0 : 10);
        return;
      }

      // Zoom on it, then zoom off
      this.zoomOnCard(card.id, { from, duration })
        .then(() => this.wait(waitingTime))
        .then(() => this.zoomOffCard({ duration }))
        .then(() => {
          if (receiving) {
            dojo.place(card.id, 'hand-container');
          } else {
            dojo.destroy(card.id);
          }
          this.notifqueue.setSynchronousDuration(10);
        });

      // Close hand modal if open
      if (this._handDialog.isDisplayed()) {
        this._handDialog.hide();
      }
    },

    notif_buyAndDestroyCard(n) {
      debug('Notif: buying and destroying a card', n);
      let card = n.args.card;

      let duration = 700;
      let waitingTime = this.getCardPreviewWaitTime();

      // Create the card if needed, and compute initial location of sliding event
      let from = this.computeSlidingAnimationFrom(card, 'reserve-' + n.args.player_id);

      if (this.isFastMode() || this.isInstantSpeed()) {
        dojo.destroy(card.id);
        this.notifqueue.setSynchronousDuration(this.isFastMode() ? 0 : 10);
        return;
      }

      // Zoom on it, then zoom off
      this.zoomOnCard(card.id, { from, duration })
        .then(() => this.wait(waitingTime))
        .then(() => this.zoomOffCard({ duration }))
        .then(() => {
          dojo.destroy(card.id);
          this.notifqueue.setSynchronousDuration(10);
        });

      // Close hand modal if open
      if (this._handDialog.isDisplayed()) {
        this._handDialog.hide();
      }
    },

    notif_debugCardToHand(n) {
      const card = n.args.card;

      if (String(card.pId) !== String(this.player_id)) {
        return;
      }

      card.location = 'hand';

      if ($(card.id)) {
        dojo.place(card.id, 'hand-container');
        this.addCustomTooltip(card.id, this.tplPlayerCard(card, true));
        this.updatePlayerBoardDimensions(card.pId);
      } else {
        this.addCard(card, 'hand-container');
      }

      this.notifqueue.setSynchronousDuration(0);
    },

    notif_debugCardInPlay(n) {
      const card = n.args.card;

      if (String(card.pId) !== String(this.player_id)) {
        return;
      }

      card.location = 'inPlay';
      this.addCard(card);
      this.notifqueue.setSynchronousDuration(0);
    },

    /**
     * Notification when someone return a card for a payment
     */
    notif_payWithCard(n) {
      debug('Notif: return a card', n);
      let card = n.args.card;
      let holder = $(card.id + '_holder');
      if (this.isFastMode() || this.isInstantSpeed()) {
        if (holder) {
          dojo.place(card.id, card.id + '_holder');
          dojo.removeClass(card.id, 'mini');
        } else {
          dojo.destroy(card.id);
        }
        return;
      }

      dojo.style(card.id, 'transform', 'scale(0.6)');
      this.slide(card.id, 'majors-button').then(() => {
        if (holder) {
          dojo.place(card.id, card.id + '_holder');
          dojo.removeClass(card.id, 'mini');
        } else {
          dojo.destroy(card.id);
        }
      });
      dojo.removeClass(card.id, 'mini');
      dojo.style(card.id, 'transform', 'scale(1)');
    },

    formatStats(stats, card) {
      const resourceToken = (res) => {
        if (res === 'stable') return '<BARN>';
        return '<' + res.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase() + '>';
      };

      const parentId = card && card.parentNode ? card.parentNode.id : '';
      const match = parentId.match(/^(?:cards-wrapper|action-cards-group)-(\d+)$/);
      const pId = match ? parseInt(match[1]) : null;
      const playerColor = pId != null && this.gamedatas.players[pId]
        ? this.gamedatas.players[pId].color
        : null;
      const colorAttr = playerColor ? ' data-color="' + playerColor + '"' : '';

      let string = '<div class="content"' + colorAttr + '>';

      // Group stats by category so related lines stay together. Within a
      // category, preserve insertion order (stable sort).
      const categoryOrder = ['used', 'gain', 'receivedPayment', 'paid', 'paidToOthers', 'saved'];
      // Longest prefix first to avoid 'paid' swallowing 'paidToOthers'.
      const prefixCheckOrder = ['receivedPayment', 'paidToOthers', 'used', 'gain', 'saved', 'paid'];
      const categoryOf = (stat) => prefixCheckOrder.find((c) => stat.startsWith(c)) ?? null;
      const ordered = Object.keys(stats).slice().sort((a, b) => {
        return categoryOrder.indexOf(categoryOf(a)) - categoryOrder.indexOf(categoryOf(b));
      });

      ordered.forEach((stat) => {
        let v = stats[stat];
        const vb = '<b>' + v + '</b>';
        let line = null;
        if (stat.startsWith('gain')) {
          const res = stat.substring(4);
          if (res === 'occupation') {
            line = dojo.string.substitute(_('Occupations: ${v}'), { v: vb });
          } else if (res === 'field') {
            line = dojo.string.substitute(_('Plows: ${v}'), { v: vb });
          } else if (res.startsWith('room') || res === 'stable') {
            const R = resourceToken(res);
            line = dojo.string.substitute(_('${R} built: ${v}'), { R, v: vb });
          } else {
            const R = resourceToken(res);
            line = dojo.string.substitute(_('${R} gained: ${v}'), { R, v: vb });
          }
        } else if (stat.startsWith('used')) {
          const label = card && card.usedText ? _(card.usedText) : _('Number of times used');
          line = dojo.string.substitute('${label}: ${v}', { label, v: vb });
        } else if (stat.startsWith('receivedPayment')) {
          const R = resourceToken(stat.substring(15));
          line = dojo.string.substitute(_('${R} from others: ${v}'), { R, v: vb });
        } else if (stat.startsWith('paidToOthers')) {
          const R = resourceToken(stat.substring(12));
          line = dojo.string.substitute(_('${R} to others: ${v}'), { R, v: vb });
        } else if (stat.startsWith('saved')) {
          const R = resourceToken(stat.substring(5));
          line = dojo.string.substitute(_('${R} saved: ${v}'), { R, v: vb });
        } else if (stat.startsWith('paid')) {
          const R = resourceToken(stat.substring(4));
          line = dojo.string.substitute(_('${R} paid: ${v}'), { R, v: vb });
        }
        if (line !== null) {
          string += this.formatStringMeeples(line) + '<br>';
        }
      });

      return string + "</div>";
    },

    placeInfobox(card) {
      html = '<div class="infobox" title="' + card.infobox + '"> ' + card.infobox + ' </div>';
      dojo.place(html, card.id);
      return card;
    },

    updateInfobox(card) {
      dojo.query('.infobox', card.id).forEach(function (node) {
        node.innerHTML = card.infobox;
        node.title = card.infobox;
      });
      return card;
    },

    removeInfobox(card) {
      dojo.query('.infobox', card.id).forEach(function (node) {
        dojo.destroy(node);
      });
      return card;
    },

    markUsableExchange(cardId) {
      dojo.addClass(cardId, 'usable');
    },

    unmarkUsableExchange(cardId) {
      dojo.removeClass(cardId, 'usable');
    },

    unmarkAllUsableExchanges() {
      dojo.query('.usable').forEach((card) => {
        dojo.removeClass(card, 'usable');
      });
    },

    /**
     * Zoom on a card
     */
    zoomOnCard(cardId, config = {}) {
      this._zoomedCard = cardId;
      this.closeTooltip(cardId);
      let originalCard = $(cardId);
      let animatedCard = dojo.clone(originalCard);
      let scale =
        (parseFloat(this.getScale(originalCard.querySelector('.player-card-resizable'))) * 100) /
        parseInt(this._cardScale);

      dojo.addClass(originalCard, 'phantom'); // Make the original card invisible
      dojo.attr(animatedCard, 'id', cardId + '_animated'); // Add a prefix to avoid duplicate id
      dojo.style(animatedCard, 'transform', `scale(${scale})`);
      dojo.empty('card-overlay');
      dojo.place(animatedCard, 'card-overlay');

      animatedCard.querySelectorAll('.card-rulings-icon').forEach((n) => n.remove());
      let card =
        this.gamedatas.playerCards.find((c) => c.id === cardId) ||
        (this.gamedatas.players[this.player_id]?.hand || []).find((c) => c.id === cardId) ||
        null;
      if (card) {
        this.maybeAddCardRulingsIcon(animatedCard, card);
      }

      // Start animation
      config.from = config.from || originalCard;
      let anim = this.slide(animatedCard, 'card-overlay', config);
      dojo.addClass('card-overlay', 'active');
      dojo.style(animatedCard, 'transform', `scale(${100 / parseInt(this._cardScale)})`);
      dojo.removeClass(animatedCard, 'mini');

      // add card stats
      this.loadSaveCard(originalCard);

      if (originalCard.stats == null || Object.keys(originalCard.stats).length == 0) {
        return anim;
      }

      statsDesc = this.formatStats(originalCard.stats, originalCard);
      html = '<h3>' + _('Card Statistics') + '</h3> ' + statsDesc;
      dojo.place(html, dojo.query('.player-card-stats', animatedCard)[0]);
      dojo.query('.player-card-stats', animatedCard).addClass('active');
      return anim;
    },

    /**
     * Zoom off a card
     */
    zoomOffCard(config = {}) {
      if (this._zoomedCard == null) return;

      let cardId = this._zoomedCard;
      let originalCard = $(cardId);
      let animatedCard = $(cardId + '_animated');

      // originalCard may be gone from the DOM (e.g. card was destroyed by another notif).
      // Fall back to instant dismiss to avoid a TypeError leaving the overlay stuck.
      if (!originalCard) {
        return this.zoomOffCardInstant();
      }

      let scale =
        (parseFloat(this.getScale(originalCard.querySelector('.player-card-resizable'))) * 100) /
        parseInt(this._cardScale);

      config.destroy = true;
      let anim = this.slide(animatedCard, cardId, config).then(() => dojo.removeClass(originalCard, 'phantom'));
      dojo.removeClass('card-overlay', 'active');
      dojo.style(animatedCard, 'transform', `scale(${scale})`);
      dojo.addClass(animatedCard, 'mini');
      dojo.query('.player-card-stats', animatedCard).removeClass('active');
      this._zoomedCard = null;
      return anim;
    },

    /**
     * Instantly dismiss the zoom overlay (no animation)
     */
    zoomOffCardInstant() {
      if (this._zoomedCard == null) {
        return Promise.resolve();
      }

      const cardId = this._zoomedCard;
      const originalCard = $(cardId);
      const animatedCard = $(cardId + '_animated');

      dojo.removeClass('card-overlay', 'active');
      dojo.empty('card-overlay');
      if (animatedCard) {
        dojo.destroy(animatedCard);
      }

      if (originalCard) {
        dojo.removeClass(originalCard, 'phantom');
      }

      this._zoomedCard = null;
      return Promise.resolve();
    },

    /********************
     ******* DRAFT *******
     *********************/
    onEnteringStateDraftPlayers(args) {
      // Detect async vs legacy: async games have playerTurns in args or gamedatas
      const isAsync = !!(args.playerTurns || this.gamedatas.draftPlayerTurns);
      if (isAsync) {
        this._onEnteringAsyncDraft(args);
      } else {
        this._onEnteringLegacyDraft(args);
      }
    },

    /**
     * Legacy sync draft: exact main-branch behavior.
     * args._private is a flat array of card objects. Type/turn/draftChoice are top-level.
     */
    _onEnteringLegacyDraft(args) {
      if (this.isSpectator) return;

      this._isDraft = true;
      this.updateHandContainer();

      // Add cards and listeners
      dojo.query('#draft-container .player-card').forEach(dojo.destroy);
      let cards = args._private ? args._private : this.gamedatas.draft;
      cards.forEach((card) => {
        let cardId = card.id;
        if (!$(cardId)) {
          this.addCard(card);
          this.slideFromLeft(cardId);
        }
        if (!this.isReadOnly()) {
          this.onClick(cardId, () => this.onClickCardDraft(cardId));
        }
      });

      // Update action button
      this._draftType = args.type;
      this.updateDraftStatus();
    },

    /**
     * Async draft: per-player turns, progress bar, waiting UI, shadow cards.
     * args._private is an object with {cards, turn, type, draftChoice, lastPool}.
     */
    _onEnteringAsyncDraft(args) {
      // Initialise progress tracking — prefer live notifications, fall back to state args,
      // then gamedatas (page load). This ensures spectators and non-active players all see it.
      if (!this._draftPlayerTurns) {
        const source = args.playerTurns || this.gamedatas.draftPlayerTurns;
        if (source) {
          this._draftPlayerTurns = source;
          this._draftTotalTurns = args.total || this.gamedatas.draftTotalTurns;
        }
      }
      if (!this._draftFirstPlayer) {
        this._draftFirstPlayer = String(args.firstPlayer || this.gamedatas.draftFirstPlayer || '');
      }
      // Normalise keys to strings to match gamedatas.players key format.
      if (this._draftPlayerTurns) {
        const normalised = {};
        Object.entries(this._draftPlayerTurns).forEach(([k, v]) => { normalised[String(k)] = v; });
        this._draftPlayerTurns = normalised;
      }

      // Toggle draft (must be set before renderDraftProgressBar checks it).
      // Spectators also need _isDraft so that updateHandContainer() keeps
      // draft-wrapper active (e.g. after onScreenWidthChange).
      this._isDraft = true;

      // Render progress bar for everyone — spectators, active, and waiting players.
      if (this.isSpectator) {
        dojo.addClass('draft-wrapper', 'spectator');
      }
      this.updateHandContainer();
      this.renderDraftProgressBar();

      if (this.isSpectator) {
        const total = this._draftTotalTurns || args.total;
        if (total <= 1 && this.gamedatas.gamestate) {
          this.gamedatas.gamestate.description = _('Draft: players are choosing their cards');
          this.updatePageTitle();
        }
        return;
      }

      // Override per-player turn from private args (they're more up-to-date).
      if (args._private && args._private.turn) {
        if (!this._draftPlayerTurns) this._draftPlayerTurns = {};
        this._draftPlayerTurns[String(this.player_id)] = args._private.turn;
      }

      // If not active (e.g. page refresh while waiting), restore the waiting UI
      if (!this.isCurrentPlayerActive()) {
        if (this._draftPlayerTurns) {
          // Show unchosen cards from the last pack as inert shadow clones
          const lastPool = args._private ? (args._private.lastPool || []) : [];
          dojo.query('#draft-container .player-card').forEach(dojo.destroy);
          const container = $('draft-container');
          if (container) {
            lastPool.forEach((card) => {
              if (!$('shadow_' + card.id)) {
                container.appendChild(this._makeDraftShadowFromData(card));
              }
            });
          }
          const myTurn = this._draftPlayerTurns[String(this.player_id)];
          const draftComplete = this._draftTotalTurns && myTurn > this._draftTotalTurns;
          const predId = draftComplete ? null : this._getDraftPredecessorId();
          if (this.gamedatas.gamestate) {
            if (draftComplete) {
              this.gamedatas.gamestate.description = _('Draft: waiting for other players to finish');
            } else {
              const predecessor = predId ? this.gamedatas.players[predId] : null;
              if (predecessor) {
                this.gamedatas.gamestate.description = _('Draft: waiting for next pack from')
                  + ` <strong style="color:#${predecessor.color}">${predecessor.name}</strong>`;
              }
            }
            this.updatePageTitle();
          }
        }
        return;
      }

      // Add cards and listeners
      dojo.query('#draft-container .player-card').forEach(dojo.destroy);
      let cards = (args._private && args._private.cards && args._private.cards.length > 0) ? args._private.cards : (this.gamedatas.draft || []);
      cards.forEach((card) => {
        let cardId = card.id;
        const alreadyInDom = !!$(cardId);
        if (!alreadyInDom) {
          this.addCard(card);
          this.slideFromLeft(cardId);
        }

        if (!this.isReadOnly()) {
          this.onClick(cardId, () => this.onClickCardDraft(cardId));
        }
      });

      // Update action button — use this player's own type from private args
      this._draftType = args._private ? args._private.type : args.type;
      this.updateDraftStatus();
      // Fix the title bar: override turn and draftChoice with this player's private args
      if (args._private && this.gamedatas.gamestate && this.gamedatas.gamestate.args) {
        if (args._private.turn) this.gamedatas.gamestate.args.turn = args._private.turn;
        if (args._private.draftChoice) this.gamedatas.gamestate.args.draftChoice = args._private.draftChoice;
        // One-shot: strip round counter and "Draft:" prefix
        const total = this._draftTotalTurns || args.total;
        if (total <= 1) {
          this.gamedatas.gamestate.descriptionmyturn = this.gamedatas.gamestate.descriptionmyturn
            .replace('(${turn}/${total}) ', '').replace('Draft: ', '');
        }
        this.updatePageTitle();
      }
    },

    renderDraftProgressBar() {
      this.renderDraftProgress();
    },

    renderDraftProgress() {
      const existing = $('agr-draft-progress-row');
      if (existing) existing.remove();
      const wrapper = $('draft-wrapper');
      if (!wrapper || !this._draftPlayerTurns
        || Object.keys(this._draftPlayerTurns).length === 0
        || !this._draftTotalTurns || this._draftTotalTurns <= 1) return;
      const row = document.createElement('div');
      row.id = 'agr-draft-progress-row';
      row.innerHTML = this._buildDraftProgressHTML();
      wrapper.insertBefore(row, wrapper.firstChild);
    },

    _buildDraftProgressHTML() {
      const total = this._draftTotalTurns;
      const turns = this._draftPlayerTurns;
      const myId = String(this.player_id);
      const fpId = this._draftFirstPlayer || '';
      const playerCount = Object.keys(this.gamedatas.players).length;
      // Pivot player appears first; remaining follow in turn order from that seat.
      // Non-spectators pivot on themselves; spectators pivot on the starting player.
      const fpNo = fpId && this.gamedatas.players[fpId] ? this.gamedatas.players[fpId].no : 1;
      const myNo = this.gamedatas.players[myId] ? this.gamedatas.players[myId].no : 0;
      const pivotNo = this.isSpectator ? fpNo : myNo;
      const players = Object.entries(this.gamedatas.players).sort(([aId, a], [bId, b]) => {
        const aOrd = ((a.no - pivotNo + playerCount) % playerCount);
        const bOrd = ((b.no - pivotNo + playerCount) % playerCount);
        return aOrd - bOrd;
      });

      let chips = '';
      players.forEach(([pid, p]) => {
        const t = turns[pid] ?? 1;
        const done = Math.max(0, t - 1);
        let pips = '';
        for (let i = 1; i <= total; i++) {
          pips += `<span class="dp-pip${i < t ? ' done' : ''}"></span>`;
        }
        chips += `<div class="dp-chip">
          <span class="dp-chip-name" style="color:#${p.color}">${p.name}</span>
          <div class="dp-chip-pips">${pips}</div>
          <span class="dp-chip-count">${done}/${total}</span>
        </div>`;
      });

      return `<div class="dp-module" data-players="${playerCount}">
        <span class="dp-title">${_('Draft progress')}</span>
        <div class="dp-chips">${chips}</div>
      </div>`;
    },

    /**
     * Convert a pool card element into an inert visual shadow:
     * clones the node (stripping event listeners), gives it a shadow_ prefix ID,
     * adds unselectable styling, and swallows all pointer events.
     */
    _makeDraftShadowFromEl(cardEl) {
      const clone = cardEl.cloneNode(true);
      clone.id = 'shadow_' + cardEl.id;
      clone.classList.add('unselectable', 'draft-pool-shadow');
      const swallow = (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); };
      ['click', 'mousedown', 'mouseup', 'pointerdown', 'pointerup', 'touchstart', 'touchend']
        .forEach((t) => clone.addEventListener(t, swallow, true));
      return clone;
    },

    /**
     * Build an inert shadow card from raw card data (used on page refresh
     * when the original element no longer exists in the DOM).
     */
    _makeDraftShadowFromData(card) {
      const tmp = document.createElement('div');
      tmp.innerHTML = this.tplPlayerCard(Object.assign({}, card, { mini: false }), false);
      const node = tmp.firstElementChild;
      node.id = 'shadow_' + card.id;
      node.classList.add('unselectable', 'draft-pool-shadow');
      const swallow = (e) => { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); };
      ['click', 'mousedown', 'mouseup', 'pointerdown', 'pointerup', 'touchstart', 'touchend']
        .forEach((t) => node.addEventListener(t, swallow, true));
      return node;
    },

    /**
     * Compute the predecessor player ID in draft order (the player who passes to us).
     * Players pass in seat order; predecessor is the player before us in that order.
     */
    _getDraftPredecessorId() {
      const players = Object.entries(this.gamedatas.players).sort(([, a], [, b]) => a.no - b.no);
      const myIndex = players.findIndex(([pid]) => pid === String(this.player_id));
      if (myIndex < 0) return null;
      return players[(myIndex - 1 + players.length) % players.length][0];
    },

    getDraftSelection() {
      return {
        occupation: dojo.query('.player-card.occupation.pending').length,
        minor: dojo.query('.player-card.minor.pending').length,
      };
    },

    onClickCardDraft(cardId) {
      if (dojo.hasClass(cardId, 'unselectable')) {
        return;
      }

      if (dojo.hasClass(cardId, 'pending')) {
        this.takeAction('actDraftRemove', { cardId }, false);
      } else {
        this.takeAction('actDraftAdd', { cardId });
      }
    },

    notif_addCardToDraftSelection(n) {
      debug('Notif: add card to draft selection', n);
      let cardId = n.args.cardId;
      dojo.addClass(cardId, 'pending');
      dojo.attr(cardId, 'data-state', n.args.pos);
      this.updateDraftStatus();

      // Compute position using state data-attr
      let cards = [...$('hand-container').querySelectorAll('.player-card')];
      let brother = cards.reduce(
        (carry, card) => carry || (card.getAttribute('data-state') > n.args.pos ? card : carry),
        null,
      );
      // Slide it
      this.slide(cardId, 'hand-container', {
        phantom: true,
        beforeBrother: brother,
      });
      if (!this.isFastMode()) {
        sortable('#hand-container');
      }
    },
    notif_removeCardFromDraftSelection(n) {
      debug('Notif: remove card to draft selection', n);
      let cardId = n.args.cardId;
      dojo.removeClass(cardId, 'pending');
      this.updateDraftStatus();
      this.slide(cardId, 'draft-container', { phantom: true });
    },

    updateDraftStatus() {
      let selection = this.getDraftSelection();
      let allSet = true;

      // Check minors (exclude shadow cards — they keep unselectable permanently)
      if (selection.minor == this._draftType.minor) {
        dojo.query('#draft-container .player-card.minor:not(.pending):not(.draft-pool-shadow)').addClass('unselectable');
      } else {
        dojo.query('#draft-container .player-card.minor:not(.pending):not(.draft-pool-shadow)').removeClass('unselectable');
        allSet = false;
      }

      // Check occupations
      if (selection.occupation == this._draftType.occupation) {
        dojo.query('#draft-container .player-card.occupation:not(.pending):not(.draft-pool-shadow)').addClass('unselectable');
      } else {
        dojo.query('#draft-container .player-card.occupation:not(.pending):not(.draft-pool-shadow)').removeClass('unselectable');
        allSet = false;
      }

      dojo.destroy('btnConfirmDraft');
      if (allSet && this.isCurrentPlayerActive()) {
        this.addPrimaryActionButton('btnConfirmDraft', _('Confirm selection'), () =>
          this.takeAction('actDraftConfirm'),
        );
      }
    },

    onUpdateActivityDraftPlayers(_args, _stats) {
      if (this._draftType != null) this.updateDraftStatus();
    },

    notif_confirmDraftSelection(n) {
      debug('Notif: confirming draft selection', n);
      dojo.removeClass(n.args.card.id, 'pending');
    },
    notif_clearDraftPools(n) {
      debug('Notif: clearing draft pools', n);
      const cards = dojo.query('#draft-container .player-card');
      // If the container is already empty (e.g. auto-pick never showed cards), do nothing.
      // Don't cancel any pending timeout — a previous clearDraftPools may still be mid-animation.
      if (cards.length === 0) return;
      cards.forEach((oCard) => this.slideToRight(oCard));
      // Use a long timeout so notif_newDraftPile can cancel it if new cards arrive
      // before old ones are destroyed (prevents a brief container-collapse glitch).
      if (this._clearDraftTimeout) clearTimeout(this._clearDraftTimeout);
      let destroyDelay = this.isInstantSpeed() ? 10 : 2000;
      this._clearDraftTimeout = setTimeout(() => {
        cards.forEach(dojo.destroy);
        this._clearDraftTimeout = null;
      }, destroyDelay);
    },

    notif_newDraftPile(n) {
      debug('Notif: new draft pile', n);
      // Cancel any pending destroy from notif_clearDraftPools (sync draft path)
      if (this._clearDraftTimeout) {
        clearTimeout(this._clearDraftTimeout);
        this._clearDraftTimeout = null;
      }

      const addNewCards = () => {
        (n.args.cards || []).forEach((card) => {
          const cardId = card.id;
          if (!$(cardId)) {
            this.addCard(card);
            this.slideFromLeft(cardId);
          }
          if (!this.isReadOnly()) {
            this.onClick(cardId, () => this.onClickCardDraft(cardId));
          }
        });
        this._draftType = n.args.type;
        this.updateDraftStatus();
        if (this.gamedatas.gamestate && this.gamedatas.gamestate.args) {
          this.gamedatas.gamestate.args.turn = n.args.turn;
          if (n.args.draftChoice) this.gamedatas.gamestate.args.draftChoice = n.args.draftChoice;
          this.updatePageTitle();
        }
      };

      const oldCards = dojo.query('#draft-container .player-card');
      if (!this.isFastMode() && !this.isInstantSpeed() && oldCards.length > 0) {
        oldCards.forEach((oCard) => this.slideToRight(oCard));
        setTimeout(() => {
          dojo.query('#draft-container .player-card').forEach(dojo.destroy);
          addNewCards();
        }, Math.round(500 * this.getAnimationSpeedMultiplier()));
      } else {
        dojo.query('#draft-container .player-card').forEach(dojo.destroy);
        addNewCards();
      }
    },

    notif_draftProgress(n) {
      debug('Notif: draft progress', n);
      // Normalise keys to strings to match gamedatas.players key format
      const normalised = {};
      Object.entries(n.args.allTurns).forEach(([k, v]) => { normalised[String(k)] = v; });
      this._draftPlayerTurns = normalised;
      this._draftTotalTurns = n.args.total;
      // Re-render the progress bar (visible to everyone during draft)
      this.renderDraftProgressBar();
      // Keep the spectator's banner turn counter up to date
      if (this.isSpectator && this.gamedatas.gamestate && this.gamedatas.gamestate.args) {
        const minTurn = Math.min(...Object.values(normalised));
        this.gamedatas.gamestate.args.turn = minTurn;
        this.updatePageTitle();
      }
    },

    notif_draftWaiting(n) {
      debug('Notif: draft waiting', n);
      // Normalise keys to strings to match gamedatas.players key format
      const normalised = {};
      Object.entries(n.args.allTurns).forEach(([k, v]) => { normalised[String(k)] = v; });
      this._draftPlayerTurns = normalised;
      this._draftTotalTurns = n.args.total;
      // Replace remaining pool cards with inert shadow clones (no event handlers)
      const container = $('draft-container');
      dojo.query('#draft-container .player-card').forEach((cardEl) => {
        const shadow = this._makeDraftShadowFromEl(cardEl);
        container.appendChild(shadow);
        dojo.destroy(cardEl);
      });
      this.renderDraftProgressBar();
      // Update the title bar and waiting text
      if (this.gamedatas.gamestate) {
        const myTurnAfter = normalised[String(this.player_id)];
        const draftComplete = myTurnAfter > n.args.total;
        if (draftComplete) {
          this.gamedatas.gamestate.description = _('Draft: waiting for other players to finish');
        } else {
          const predecessor = n.args.predecessorId ? this.gamedatas.players[String(n.args.predecessorId)] : null;
          if (predecessor) {
            this.gamedatas.gamestate.description = _('Draft: waiting for next pack from')
              + ` <strong style="color:#${predecessor.color}">${predecessor.name}</strong>`;
          }
        }
        this.updatePageTitle();
      }
    },

    notif_draftIsOver(n) {
      debug('Notif: draft is over');

      dojo.query('#draft-container .player-card').forEach(dojo.destroy);

      this._isDraft = false;
      this._draftPlayerTurns = null;
      this._draftTotalTurns = null;
      this._draftFirstPlayer = null;
      const row = $('agr-draft-progress-row');
      if (row) row.remove();
      dojo.removeClass('draft-wrapper', 'spectator');
      this.updateHandContainer();
    },

    slideFromLeft(elem) {
      if (this.isFastMode() || this.isInstantSpeed()) return;
      elem = typeof elem == 'string' ? $(elem) : elem;
      let x = elem.offsetWidth + elem.offsetLeft + 30;
      dojo.addClass(elem, 'notransition');
      dojo.style(elem, 'opacity', '0');
      dojo.style(elem, 'left', -x + 'px');
      elem.offsetHeight;
      dojo.removeClass(elem, 'notransition');

      dojo.style(elem, 'opacity', '1');
      dojo.style(elem, 'left', '0px');
    },

    slideToRight(elem) {
      if (this.isFastMode() || this.isInstantSpeed()) return;
      elem = typeof elem == 'string' ? $(elem) : elem;
      let stack = elem.parentNode;
      let x = stack.offsetWidth - elem.offsetLeft + 100;
      dojo.style(elem, 'left', x + 'px');
    },

    /*********************************
    ******* LIVING HAND REFILL *******
    *********************************/

    setLivingHandDraftMode(isOn) {
      if (isOn) {
        if (this.isSpectator) return;
        if (!this.isCurrentPlayerActive()) return;
      }

      if (this._isDraft === isOn) return;
      this._isDraft = isOn;
      this.updateHandContainer();
    },

    getLivingHandOfferFromArgs(args) {
      if (!args) return [];
      if (args._private && args._private.offer) return args._private.offer;
      if (args.offer) return args.offer;
      return [];
    },

    renderLivingHandOffer(offerCards, animate = true) {
      dojo.query('#draft-container .player-card').forEach(dojo.destroy);
      dojo.destroy('btnConfirmDraft');

      (offerCards || []).forEach((card) => {
        const cardId = '' + card.id;

        if (!$(cardId)) {
          this.addCard(card);
        }

        const el = $(cardId);
        if (!el) return;

        if (animate) {
          dojo.addClass(el, 'notransition');
          dojo.style(el, 'opacity', '0');
        }

        dojo.place(el, 'draft-container');

        if (animate) {
          el.offsetHeight; // force reflow
          dojo.removeClass(el, 'notransition');
          dojo.style(el, 'opacity', '1');
        }
      });

      this.enableLivingHandOfferClicks();
    },

    enableLivingHandOfferClicks() {
      if (this.isReadOnly()) return;

      dojo.query('#draft-container .player-card').forEach((node) => {
        const id = node && node.id;
        if (!id) return;

        if (dojo.hasClass(id, 'selectable')) return;

        this.onClick(id, () => this.onClickCardLivingHand(id));
      });
    },

    onEnteringStateLivingHandRefill(args) {
      if (this.isSpectator) return;
      if (!this.isCurrentPlayerActive()) return;
      this.setLivingHandDraftMode(true);

      const offer = this.getLivingHandOfferFromArgs(args);
      if (offer && offer.length) {
        const offerIds = offer.map((c) => '' + c.id);
        const shownIds = dojo.query('#draft-container .player-card').map((n) => n.id);

        const same =
          shownIds.length === offerIds.length &&
          offerIds.every((id) => shownIds.includes(id));

        if (!same) {
          this.renderLivingHandOffer(offer, false);
          return;
        }
      }

      this.enableLivingHandOfferClicks();
      dojo.destroy('btnConfirmDraft');
    },

    onLeavingStateLivingHandRefill() {
      dojo.query('#draft-container .player-card').forEach(dojo.destroy);
      dojo.destroy('btnConfirmDraft');

      this.setLivingHandDraftMode(false);
    },

    onClickCardLivingHand(cardId) {
      if (this.isReadOnly()) return;
      if (!$(cardId)) return;
      if (dojo.hasClass(cardId, 'unselectable')) return;

      // Anti double-click until server responds
      dojo.addClass(cardId, 'unselectable');
      dojo.removeClass(cardId, 'selectable');

      this.takeAction('actLivingHandPick', { cardId });
    },

    onEnteringStateLivingHandPassingDecision(args) {
      if (this.isSpectator) return;
      if (!this.isCurrentPlayerActive()) return;

      this.setLivingHandDraftMode(false);

      let card = null;
      if (!args) {
        card = null;
      } else if (args._private && args._private.card) {
        card = args._private.card;
      } else if (args.card) {
        card = args.card;
      } else {
        card = null;
      }
      let cardName = _('this card');
      if (card && card.name) {
        cardName = _(card.name);
      } else if (args && args.card_name) {
        cardName = _(args.card_name);
      }

      this.addPrimaryActionButton(
        'btnLivingHandPassKeep',
        dojo.string.substitute(_('Keep ${card_name}'), { card_name: cardName }),
        () => this.takeAction('actLivingHandPassDecision', { decision: 'keep' }),
      );
      this.addSecondaryActionButton(
        'btnLivingHandPassDecline',
        dojo.string.substitute(_('Decline ${card_name}'), { card_name: cardName }),
        () => this.takeAction('actLivingHandPassDecision', { decision: 'decline' }),
      );

      if (this.prefs[HAND_CARDS].value == 0) {
        this.openHandModal();
      }
    },

    notif_livingHandOfferCreated(n) {
      debug('Notif: living hand offer created', n);
      if (!this.isCurrentPlayerActive()) return;

      this.setLivingHandDraftMode(true);

      const offer = (n.args && n.args.offer) ? n.args.offer : [];
      this.renderLivingHandOffer(offer, true);
    },

    notif_livingHandPicked(n) {
      debug('Notif: living hand picked', n);

      const cardId = n.args.cardId;
      if (!$(cardId)) return;

      dojo.addClass(cardId, 'unselectable');
      dojo.removeClass(cardId, 'selectable');

      let cards = [...$('hand-container').querySelectorAll('.player-card')];
      let brother = cards.reduce(
        (carry, card) => carry || (card.getAttribute('data-state') > n.args.pos ? card : carry),
        null,
      );

      this.slide(cardId, 'hand-container', { destroy: true, beforeBrother: brother });
    },

    notif_livingHandOfferCleared(n) {
      debug('Notif: living hand offer cleared', n);

      dojo.query('#draft-container .player-card').forEach(dojo.destroy);

      if (n.args && n.args.done) {
        this.setLivingHandDraftMode(false);
      }
    },

  });
});