Source: client/scripts/versus/versus.js

/** @module versus */

import {
  selectCard,
  joinInstance,
  startGame,
  attachGameCallbackFns,
  sendInitializationRequest,
} from './socket.js';
import {
  updateProfile,
  getScore,
  initializePlayers,
  setRemainingCards,
  getRemainingCards,
  setNumOpponentCards,
  getNumOpponentCards,
  setGameIsStarted,
  getGameIsStarted,
  setSelfSelectedCard,
  setOppSelectedCard,
  setRoundWinnerUUID,
  getRoundWinnerUUID,
  createNewRoundState,
  getRoundNumber,
  getOppHasPlayedRound,
  getSelfHasPlayedRound,
  clearGameState,
  setGameWinnerUUID,
  getGameWinnerUUID,
  getOpponentUUID,
  getCurrentWorldEvent,
  getSelfSelectedCard,
} from './store.js';
import { clearChat } from './chat.js';
import { getRandFromArr, getWorldEventInfo } from './util.js';
import { getPlayerUUID } from './../profile.js';
import * as Types from './types.js';
import { starsInit, removeStars } from '../stars.js';
import {
  SOUND_EFFECTS,
  playSoundEffect,
  playBackgroundMusic,
} from '../sound.js';

const OPPONENT_MOVE_MESSAGE = "Waiting for opponent's move...";
const USER_MOVE_MESSAGE = 'Select and play a card';

const NUM_ROUNDS = 5;

const WORLD_EVENT_MODAL_DELAY_MS = 250;
const GAME_END_ANIMATION_DELAY_MS = 500;
const DEALING_CARD_DELAY_SEC = 0.5;

/**
 * TimeoutID for use by copyGameCodeToClipboard()
 * @type { number|undefined }
 */
let copyGameCodeTimeoutID;

/**
 * Promise that will resolve when the current card animation process
 * is complete, for use with other out-of-context animation functions
 * @type { Promise<void> }
 */
let currentAnimationPromise;

/**
 * Updates display of current user lobby, including game code and active players
 * @param { {
 *  gameCode: number,
 *  profileList: Types.ServerToClientProfile[]
 * } } instanceInfo new info about game instance
 */
export function handleUpdateInstance({ gameCode, profileList } = {}) {
  const selfGameCodeReadOnlyInputEl = document.querySelector('#self_game_code');
  const lobbyProfileListEl = document.querySelector('#lobby_profile_list');
  const startGameButtonEl = document.querySelector('#start_game_button');

  selfGameCodeReadOnlyInputEl.value = gameCode;

  lobbyProfileListEl.replaceChildren(
    ...profileList.map((profile) => {
      updateProfile(profile);

      const profileListItemEl = document.createElement('li');

      const versusAvatarEl = document.createElement('versus-avatar');
      const versusUsernameEl = document.createElement('versus-username');
      versusAvatarEl.setAttribute('uuid', profile.uuid);
      versusUsernameEl.setAttribute('uuid', profile.uuid);

      profileListItemEl.replaceChildren(versusAvatarEl, versusUsernameEl);

      return profileListItemEl;
    }),
  );

  initializePlayers(profileList.map((profile) => profile.uuid));

  if (profileList.length === 2) {
    startGameButtonEl.disabled = false;
    startGameButtonEl.focus();
  } else {
    startGameButtonEl.disabled = true;
  }
} /* handleUpdateInstance */

/**
 * Displays start of game instance, including cards being drawn
 * @param { Types.Card[] } drawnCardNames cards "drawn" by the user, passed from server
 */
export function handleGameStart(drawnCardNames) {
  setRemainingCards(drawnCardNames);
  setNumOpponentCards(drawnCardNames.length);
  setGameIsStarted();
  createCardElements();
  initializeScoreboard();
  handleStartRound();
  toggleToGameboardView();
} /* handleGameStart */

/**
 * Initializes score, round, and time remaining counters
 * located on the game board
 */
function initializeScoreboard() {
  const scoreInfoWrapperEl = document.querySelector('#score_info');
  const roundNumberEl = document.querySelector('#round_number');

  scoreInfoWrapperEl.replaceChildren(
    ...[getOpponentUUID(), getPlayerUUID()].map((UUID) => {
      const scoreInfoEl = document.createElement('p');
      const scoreCounterEl = document.createElement('span');
      scoreCounterEl.innerText = getScore(UUID);

      const versusUsernameEl = document.createElement('versus-username');
      versusUsernameEl.setAttribute('uuid', UUID);

      scoreInfoEl.replaceChildren(versusUsernameEl, scoreCounterEl);

      return scoreInfoEl;
    }),
  );

  roundNumberEl.innerText = getRoundNumber();
} /* initializeScoreboard */

/**
 * Populates scoreboard scores with most recent scores from store
 */
function updateScoreboardScores() {
  const scoreInfoWrapperEl = document.querySelector('#score_info');
  const scoreInfoEls = scoreInfoWrapperEl.querySelectorAll('p');

  [...scoreInfoEls].forEach((scoreInfoEl) => {
    const versusUsernameEl = scoreInfoEl.querySelector('versus-username');
    const scoreCounterEl = scoreInfoEl.querySelector('span');

    scoreCounterEl.innerText = getScore(versusUsernameEl.getAttribute('uuid'));
  });
} /* updateScoreboardScores */

/**
 * Populates scoreboard round number with current round from store
 */
function updateScoreboardRoundNumber() {
  const roundNumberEl = document.querySelector('#round_number');

  roundNumberEl.innerText = getRoundNumber();
} /* updateScoreboardRoundNumber */

/**
 * Animates card sliding into either user's or opponent's hand, including applicable
 * sound effect(s)
 * @param { HTMLElement } cardEl versus-card element to attach animation to
 * @param versusCardEl
 * @param { number } delaySec seconds of delay until animation should start
 */
function animateCardDealing(versusCardEl, delaySec) {
  versusCardEl.classList.add('player-card');
  versusCardEl.style.setProperty('animation-delay', `${delaySec}s`);
  versusCardEl.addEventListener(
    'animationstart',
    () => {
      playSoundEffect(SOUND_EFFECTS.SWISH);
    },
    { once: true },
  );
  versusCardEl.addEventListener(
    'animationend',
    () => {
      versusCardEl.classList.remove('player-card');
    },
    { once: true },
  );
} /* animateCardDealing */

/**
 * Adds card images to the DOM at start of game
 */
function createCardElements() {
  const userCardWrapperEl = document.querySelector('#user_cards');
  const opponentCardWrapperEl = document.querySelector('#opponent_cards');

  userCardWrapperEl.replaceChildren(
    ...getRemainingCards().map((remainingCard, index) => {
      const versusCardEl = document.createElement('versus-card');

      versusCardEl.setAttribute('variant', 'front');
      versusCardEl.setAttribute('suite', remainingCard.suite);
      versusCardEl.setAttribute('number', remainingCard.number);

      versusCardEl.addEventListener('click', handleCardSelection);

      animateCardDealing(versusCardEl, index * DEALING_CARD_DELAY_SEC);

      return versusCardEl;
    }),
  );

  opponentCardWrapperEl.replaceChildren(
    ...Array.from({ length: getNumOpponentCards() }).map((_, index) => {
      const versusCardEl = document.createElement('versus-card');

      versusCardEl.setAttribute('variant', 'back');
      versusCardEl.toggleAttribute('disabled', true);

      animateCardDealing(versusCardEl, index * DEALING_CARD_DELAY_SEC);

      return versusCardEl;
    }),
  );
} /* createCardElements */

/**
 * Switch to view of game board
 */
function toggleToGameboardView() {
  const lobbyWrapperEl = document.querySelector('#lobby_menu');
  const gameBoardWrapperEl = document.querySelector('#game_board');
  const leaveGameButtonEl = document.querySelector('#leave_game_button');
  const homeButtonEl = document.querySelector('#home_button');

  lobbyWrapperEl.classList.add('hidden');
  homeButtonEl.classList.add('hidden');
  gameBoardWrapperEl.classList.remove('hidden');
  leaveGameButtonEl.classList.remove('hidden');

  removeStars();
} /* toggleToGameboardView */

/**
 * Switch to view of lobby, and focus on game code input element
 */
function toggleToLobbyView() {
  const lobbyWrapperEl = document.querySelector('#lobby_menu');
  const gameBoardWrapperEl = document.querySelector('#game_board');
  const leaveGameButtonEl = document.querySelector('#leave_game_button');
  const homeButtonEl = document.querySelector('#home_button');
  const outBoundGameCodeInputEl = document.querySelector('#outbound_game_code');

  gameBoardWrapperEl.classList.add('hidden');
  leaveGameButtonEl.classList.add('hidden');
  lobbyWrapperEl.classList.remove('hidden');
  homeButtonEl.classList.remove('hidden');

  outBoundGameCodeInputEl.focus();

  starsInit();
} /* toggleToLobbyView */

/**
 * Rebuilds entire game board; can be used to resolve errors and in the case of
 * re-joining instances
 */
export function refreshEntireGame() {
  if (!getGameIsStarted()) return;

  initializeScoreboard();
  createCardElements();

  if (getOppHasPlayedRound()) refreshOppSelectedCard();
  if (getSelfHasPlayedRound()) refreshUserSelectedCard();

  displayWorldEventLegendImage();

  const gameWinnerUUID = getGameWinnerUUID();
  const roundWinnerUUID = getRoundWinnerUUID();
  if (gameWinnerUUID) {
    displayWinner(gameWinnerUUID, 'game');
  } else if (roundWinnerUUID) {
    displayWinner(roundWinnerUUID, 'round');
  }

  toggleToGameboardView();
} /* refreshEntireGame */

/**
 * Displays (without animation) the legend image for the current
 * world event associated to the game, for use when triggering
 * a complete refresh of the game state
 */
function displayWorldEventLegendImage() {
  const legendImageEl = document.querySelector('#legend_card_wrapper img');

  const worldEventInfo = getWorldEventInfo(getCurrentWorldEvent());

  legendImageEl.src = worldEventInfo.imgPath;
  legendImageEl.alt = worldEventInfo.eventDescription;
} /* displayWorldEventLegendImage */

/**
 * Rebuilds and replaces the opponent's (unrevealed) selected card,
 * for use during full-game refresh
 */
function refreshOppSelectedCard() {
  const oppCardSlotEl = document.querySelector('#opp_played_card');
  const versusCardEl = document.createElement('versus-card');

  versusCardEl.setAttribute('variant', 'back');
  versusCardEl.toggleAttribute('disabled', true);

  oppCardSlotEl.replaceChildren(versusCardEl);
} /* refreshUserSelectedCard */

/**
 * Rebuilds and replaces the users's selected card, for use during\
 * full-game refresh
 */
function refreshUserSelectedCard() {
  const selfCardSlotEl = document.querySelector('#self_played_card');
  const versusCardEl = document.createElement('versus-card');

  const selectedCard = getSelfSelectedCard();

  versusCardEl.setAttribute('variant', 'front');
  versusCardEl.setAttribute('suite', selectedCard.suite);
  versusCardEl.setAttribute('number', selectedCard.number);
  versusCardEl.toggleAttribute('disabled', true);

  selfCardSlotEl.replaceChildren(versusCardEl);
} /* refreshUserSelectedCard */

/**
 * Displays fact that opponent user has played a card, without yet revealing what
 * that card is
 * @param {{ ignoreAwait: boolean }} [options]
 */
export async function handleOpponentMove({ ignoreAwait } = {}) {
  if (!ignoreAwait) await currentAnimationPromise;

  const oppDeckSlotEl = document.querySelector('#opponent_cards');
  const oppCardSlotEl = document.querySelector('#opp_played_card');

  const oppRemainingVersusCardEls =
    oppDeckSlotEl.querySelectorAll('versus-card');
  const oppVersusCardEl = getRandFromArr(oppRemainingVersusCardEls);

  setOppSelectedCard('played');

  await oppVersusCardEl.translateToContainer(oppCardSlotEl);
} /* handleOpponentMove */

/**
 * Displays message that a user won the round/game
 * @param { Types.UUID } winnerUUID winner of round/game
 * @param { 'round'|'game'} variant decorator on win message
 */
function displayWinner(winnerUUID, variant) {
  const versusUsernameEl = document.createElement('versus-username');
  versusUsernameEl.setAttribute('uuid', winnerUUID);
  updateCurrentInstruction(versusUsernameEl, ` won the ${variant}!`);
} /* displayRoundWinner */

/**
 * Animation that shows which card won for each show
 * @param { Types.UUID } roundWinnerUUID unique identifier of round winner
 * @returns { Promise<void> } promise that resolves when animation is complete
 */
async function roundWinnerAnimationCard(roundWinnerUUID) {
  const oppVersusCardEl = document.querySelector(
    '#opp_played_card versus-card',
  );
  const selfPlayedVersusCardEl = document.querySelector(
    '#self_played_card versus-card',
  );

  if (roundWinnerUUID === getPlayerUUID()) {
    // user wins round
    selfPlayedVersusCardEl.classList.add('winner-card-user');
    oppVersusCardEl.classList.add('loser-card');
  } else {
    // opp wins round
    selfPlayedVersusCardEl.classList.add('loser-card');
    oppVersusCardEl.classList.add('winner-card-opp');
  }

  return new Promise((resolve) => {
    selfPlayedVersusCardEl.addEventListener(
      'animationend',
      () => {
        setTimeout(() => {
          [selfPlayedVersusCardEl, oppVersusCardEl].forEach((el) => {
            el.classList.remove(
              'winner-card-opp',
              'winner-card-user',
              'loser-card',
            );
          });
          resolve();
        }, 500);
      },
      { once: true },
    );
  });
} /* roundWinnerAnimationCard */

/**
 * Animation that shows either "YOU WON" or "YOU LOST" depending on the outcome for each round
 * @param { Types.UUID } roundWinnerUUID unique identifier of round winner
 * @returns { Promise<void> } promise that resolves when animation is complete
 */
async function roundWinnerAnimationText(roundWinnerUUID) {
  const oppVersusCardEl = document.querySelector(
    '#opp_played_card versus-card',
  );
  const selfPlayedVersusCardEl = document.querySelector(
    '#self_played_card versus-card',
  );
  const roundWinnerTextEl = document.querySelector('#round_end_text');
  const nextRoundWrapperEl = document.querySelector('.next-round');

  // changes the text depending on who won the round
  const isUserWinner = roundWinnerUUID === getPlayerUUID();
  roundWinnerTextEl.replaceChildren('YOU ', isUserWinner ? 'WON' : 'LOST', '!');
  roundWinnerTextEl.classList.toggle('loser-text', !isUserWinner);

  oppVersusCardEl.classList.add('no-vis');
  selfPlayedVersusCardEl.classList.add('no-vis');
  nextRoundWrapperEl.classList.add('next-round-animation');

  playSoundEffect(
    isUserWinner ? SOUND_EFFECTS.ROUND_WIN : SOUND_EFFECTS.ROUND_LOSE,
  );

  return new Promise((resolve) => {
    nextRoundWrapperEl.addEventListener(
      'animationend',
      () => {
        setTimeout(() => {
          nextRoundWrapperEl.classList.remove('next-round-animation');
          resolve();
        }, 500);
      },
      { once: true },
    );
  });
} /* roundWinnerAnimationText */

/**
 * Calls animateRevealCards and awaits response, for use by other dependent animations
 * @param { Types.Card } opponentSelectedCard information of card chosen by opponent
 * @param { Types.ServerToClientProfile } roundWinner profile data of (user/opponent) who won round
 */
export async function handleRevealCards(opponentSelectedCard, roundWinner) {
  currentAnimationPromise = animateRevealCards(
    opponentSelectedCard,
    roundWinner,
  );

  await currentAnimationPromise;
} /* handleRevealCards */

/**
 * Handles async animation of card reveal promise, for use by handleRevealCards()
 * @param { Types.Card } opponentSelectedCard information of card chosen by opponent
 * @param { Types.ServerToClientProfile } roundWinner profile data of (user/opponent) who won round
 */
async function animateRevealCards(opponentSelectedCard, roundWinner) {
  const selfPlayedVersusCardEl = document.querySelector(
    '#self_played_card versus-card',
  );

  // wait until the opponent playing card animation completes
  if (!getOppHasPlayedRound()) await handleOpponentMove({ ignoreAwait: true });

  await selfPlayedVersusCardEl.getCardTranslationPromise();

  const oppVersusCardEl = document.querySelector(
    '#opp_played_card versus-card',
  );

  oppVersusCardEl.setAttribute('suite', opponentSelectedCard.suite);
  oppVersusCardEl.setAttribute('number', opponentSelectedCard.number);
  await oppVersusCardEl.flipCard('front');

  setOppSelectedCard(opponentSelectedCard);
  setRoundWinnerUUID(roundWinner.uuid);

  // plays the round winner animation
  await roundWinnerAnimationCard(roundWinner.uuid);
  await roundWinnerAnimationText(roundWinner.uuid);

  updateScoreboardScores();

  // starts the next round
  if (getRoundNumber() < NUM_ROUNDS) handleStartRound();
} /* animateRevealCards */

/**
 * Handles reset of UI at the start of each new round
 */
export function handleStartRound() {
  const oppCardSlotEl = document.querySelector('#opp_played_card');
  const selfCardSlotEl = document.querySelector('#self_played_card');

  oppCardSlotEl.replaceChildren();
  selfCardSlotEl.replaceChildren();

  updateCurrentInstruction(USER_MOVE_MESSAGE);
  createNewRoundState();

  updateScoreboardRoundNumber();
} /* handleStartRound */

/**
 * Displays end-of-game information, namely who won
 * @param { Types.ServerToClientProfile } gameWinner profile data of (user/opponent) who won game
 */
export async function handleGameEnd(gameWinner) {
  const gameWinnerYouTitleEl = document.querySelector('#game_winner_you');
  const gameWinnerOppTitleEl = document.querySelector('#game_winner_opp');

  setGameWinnerUUID(gameWinner.uuid);
  updateCurrentInstruction();

  await currentAnimationPromise;
  await new Promise((r) => setTimeout(r, GAME_END_ANIMATION_DELAY_MS));

  const isUserWinner = gameWinner.uuid === getPlayerUUID();

  if (isUserWinner) {
    gameWinnerYouTitleEl.classList.add('game-winner-animation');
    playSoundEffect(SOUND_EFFECTS.GAME_WIN);
  } else {
    gameWinnerOppTitleEl.classList.add('game-winner-animation');
    playSoundEffect(SOUND_EFFECTS.GAME_LOSE);
  }
} /* handleGameEnd */

/**
 * Handles world event action, updating the game legend and triggering popup
 * @param { string } worldEvent world event that was triggered
 * @param worldEventName
 */
export async function handleWorldEvent(worldEventName) {
  // wait for current animation to end + constant delay
  await currentAnimationPromise;
  await new Promise((r) => setTimeout(r, WORLD_EVENT_MODAL_DELAY_MS));

  const currentWorldEventMeta = document.querySelector('#current_world_event');
  const gameLegend = document.querySelector('#game_legend_img');
  const worldEventModal = document.querySelector('#world_event_modal');

  currentWorldEventMeta.setAttribute('content', worldEventName);

  const worldEventInfo = getWorldEventInfo(worldEventName);
  if (worldEventInfo.eventName === 'Default') {
    gameLegend.src = './assets/images/game_legend.webp';
    gameLegend.alt =
      'Game legend explaining that wands beat cups, cups beat swords, swords beat wands, and pentacles are neutral';
    return;
  }

  // update world event popup
  if (worldEventName !== Types.WORLD_EVENTS.NONE) {
    const newLegendImgEl = document.createElement('img');
    const worldEventModalImgWrapperEl = document.querySelector(
      '#world_event_modal_img_wrapper',
    );
    const worldEventModalNameEl = document.querySelector(
      '#world_event_modal_name',
    );
    const worldEventModalDescEl = document.querySelector(
      '#world_event_modal_desc',
    );

    newLegendImgEl.src = worldEventInfo.imgPath;
    newLegendImgEl.id = 'world-event-popup';
    newLegendImgEl.alt = worldEventInfo.eventDescription;

    worldEventModalNameEl.innerText = worldEventInfo.eventName;
    worldEventModalDescEl.innerText = worldEventInfo.eventDescription;
    worldEventModalImgWrapperEl.replaceChildren(newLegendImgEl);

    worldEventModal.showModal();
  }
} /* handleWorldEvent */

/**
 * Animates world event popup to replace current legend;
 * at the end of the animation, the legend will be moved
 * into the container in the DOM as well
 */
function translateWorldEvent() {
  const gameLegend = document.querySelector('#game_legend_img');
  const worldEventPopupImg = document.querySelector('#world-event-popup');

  /* calculate difference between current and desired position */
  const worldEventPopupImgRect = worldEventPopupImg.getBoundingClientRect();
  const gameLegendElRect = gameLegend.getBoundingClientRect();

  const diffXPos = worldEventPopupImgRect.left - gameLegendElRect.left;
  const diffYPos = worldEventPopupImgRect.top - gameLegendElRect.top;

  const scaleXDim = gameLegendElRect.width / worldEventPopupImgRect.width;
  const scaleYDim = gameLegendElRect.height / worldEventPopupImgRect.height;

  /* swap and translate */
  gameLegend.parentElement.replaceChildren(worldEventPopupImg);
  worldEventPopupImg.id = 'game_legend_img';

  worldEventPopupImg.animate(
    [
      {
        transform: `translate(${diffXPos}px, ${diffYPos}px) scale(${scaleXDim}, ${scaleYDim})`,
      },
      {},
    ],
    {
      duration: 250,
    },
  );
} /* translateWorldEvent*/

/**
 * Handles complete return to lobby, and reset of all currentl game state and displays
 */
function returnToLobby() {
  toggleToLobbyView();
  clearGameState();
  clearChat();
  sendInitializationRequest();
} /* returnToLobby */

/**
 * Handles event where instance is completely closed out, including displaying a message
 * to the user and forcing them back to the lobby
 */
export function handleInstanceClosed() {
  const instClosedModalEl = document.querySelector('#instance_closed_modal');
  const confirmLeaveModalEl = document.querySelector('#confirm_leave_modal');

  instClosedModalEl.addEventListener('close', returnToLobby, { once: true });

  confirmLeaveModalEl.close();
  instClosedModalEl.showModal();
} /* handleInstanceClosed */

/**
 * Inserts new content into the instruction box, overriding any existing content
 * @param { ...(string|Node) } newChildEls new elements or strings to insert
 */
function updateCurrentInstruction(...newChildEls) {
  const currentInstructionEl = document.querySelector('#current_instruction');

  currentInstructionEl.replaceChildren(...newChildEls);
} /* updateCurrentInstruction */

/**
 * Relays card selection to server during gameplay, and relocates
 * corresponding card to center screen
 * @param { MouseEvent } e click event passed by event listener
 */
async function handleCardSelection(e) {
  const selectedVersusCardEl = e.currentTarget;
  const selectedCardInputEl = selectedVersusCardEl.querySelector('input');
  const selfPlayedCardSlotEl = document.querySelector('#self_played_card');

  const selectedCard = selectedCardInputEl.value;
  if (!selectedCard || getSelfHasPlayedRound()) return;

  selectCard(JSON.parse(selectedCard));
  setSelfSelectedCard(selectedCard);

  selectedVersusCardEl.toggleAttribute('disabled', true);

  updateCurrentInstruction(OPPONENT_MOVE_MESSAGE);

  await selectedVersusCardEl.translateToContainer(selfPlayedCardSlotEl);
} /* handleCardSelection */

/**
 * Removes everything but digits 0-9 from the game code input box, called
 * whenever the value changes
 */
function sanitizeGameCode() {
  const outboundGameCodeInputEl = document.querySelector('#outbound_game_code');

  const DIGIT_STRING = '0123456789';

  const sanitizedInputValue = Array.from(outboundGameCodeInputEl.value)
    .filter((c) => DIGIT_STRING.includes(c))
    .join('');

  outboundGameCodeInputEl.value = sanitizedInputValue;
} /* sanitizeGameCode */

/**
 * Relays attempt to join new game instance to server while in lobby
 */
function sendJoinInstance() {
  const outboundGameCodeInputEl = document.querySelector('#outbound_game_code');

  const gameCode = outboundGameCodeInputEl.value;
  outboundGameCodeInputEl.value = '';

  if (!gameCode) return;

  joinInstance(gameCode);
} /* sendJoinInstance */

/**
 * Provides user with a modal asking them if they indeed want to leave the game
 * instance
 */
function handleLeaveGame() {
  const confirmLeaveModalEl = document.querySelector('#confirm_leave_modal');
  const confirmLeaveButtonEl = document.querySelector('#confirm_leave_button');

  const confirmLeaveClickHandler = () => {
    confirmLeaveModalEl.close();
    returnToLobby();
  };

  confirmLeaveButtonEl.addEventListener('click', confirmLeaveClickHandler);

  confirmLeaveModalEl.addEventListener('close', () => {
    confirmLeaveButtonEl.removeEventListener('click', confirmLeaveClickHandler);
  });

  confirmLeaveModalEl.showModal();
} /* handleLeaveGame */

/**
 * Copies game code to user's clipboard (i.e., so they can paste it instead of having
 * to remember it)
 */
async function copyGameCodeToClipboard() {
  const selfGameCodeReadOnlyInputEl = document.querySelector('#self_game_code');
  const copyGameCodeButtonEl = document.querySelector('#copy_game_code_button');

  navigator.clipboard
    .writeText(
      'Play Tarot, but a Game with me!\n\n' +
        'https://tarot-game-client.netlify.app\n\n' +
        `Game code: ${selfGameCodeReadOnlyInputEl.value}`,
    )
    .then(() => {
      clearTimeout(copyGameCodeTimeoutID);
      copyGameCodeButtonEl.classList.add('copy-successful');
    })
    .then(() => {
      copyGameCodeTimeoutID = setTimeout(() => {
        copyGameCodeButtonEl.classList.remove('copy-successful');
      }, 3_000);
    });
} /* copyGameCodeToClipboard */

/**
 * Shows modal explaining the rules to a user
 */
function showRulesModal() {
  const rulesModalEl = document.querySelector('#rules_modal');

  rulesModalEl.showModal();
} /* handleCloseRules */

/**
 * Shows modal explaining how game codes work to the user
 */
function showGameLobbyInfoModal() {
  const gameLobbyInfoModalEl = document.querySelector('#game_lobby_info_modal');

  gameLobbyInfoModalEl.showModal();
} /* showGameLobbyInfoModal */

/**
 * Initializes Versus game; initializes WebSocket, connects appropriate callbacks,
 * and activates event listeners
 */
export function initializeVersus() {
  const joinGameButtonEl = document.querySelector('#join_game_button');
  const startGameButtonEl = document.querySelector('#start_game_button');
  const leaveGameButtonEl = document.querySelector('#leave_game_button');
  const copyGameCodeButtonEl = document.querySelector('#copy_game_code_button');
  const gameCodeInfoButtonEl = document.querySelector('#game_code_info_button');
  const openRulesButtonEl = document.querySelector('#open_rules_button');
  const legendInfoButtonEl = document.querySelector('#legend_info_button');
  const outboundGameCodeInputEl = document.querySelector('#outbound_game_code');
  const worldEventButtonEl = document.querySelector('#world_event_button');

  attachGameCallbackFns({
    handleUpdateInstance,
    handleGameStart,
    handleOpponentMove,
    handleRevealCards,
    handleStartRound,
    handleGameEnd,
    handleWorldEvent,
    handleInstanceClosed,
    refreshEntireGame,
  });

  joinGameButtonEl.addEventListener('click', sendJoinInstance);
  startGameButtonEl.addEventListener('click', startGame);
  leaveGameButtonEl.addEventListener('click', handleLeaveGame);
  copyGameCodeButtonEl.addEventListener('click', copyGameCodeToClipboard);
  gameCodeInfoButtonEl.addEventListener('click', showGameLobbyInfoModal);
  openRulesButtonEl.addEventListener('click', showRulesModal);
  legendInfoButtonEl.addEventListener('click', showRulesModal);
  outboundGameCodeInputEl.addEventListener('input', sanitizeGameCode);
  outboundGameCodeInputEl.addEventListener('keypress', (e) => {
    if (e.key === 'Enter') sendJoinInstance();
  });
  worldEventButtonEl.addEventListener('click', translateWorldEvent);

  starsInit();
  playBackgroundMusic();
} /* initializeVersus */