/** * The client-side Cards document which extends the common BaseCards model. * Each Cards document contains CardsData which defines its data schema. * @extends foundry.documents.BaseCards * @mixes ClientDocumentMixin * * @see {@link CardStacks} The world-level collection of Cards documents * @see {@link CardsConfig} The Cards configuration application */ class Cards extends ClientDocumentMixin(foundry.documents.BaseCards) { /** * Provide a thumbnail image path used to represent this document. * @type {string} */ get thumbnail() { return this.img; } /** * The Card documents within this stack which are available to be drawn. * @type {Card[]} */ get availableCards() { return this.cards.filter(c => (this.type !== "deck") || !c.drawn); } /** * The Card documents which belong to this stack but have already been drawn. * @type {Card[]} */ get drawnCards() { return this.cards.filter(c => c.drawn); } /** * Returns the localized Label for the type of Card Stack this is * @type {string} */ get typeLabel() { switch ( this.type ) { case "deck": return game.i18n.localize("CARDS.TypeDeck"); case "hand": return game.i18n.localize("CARDS.TypeHand"); case "pile": return game.i18n.localize("CARDS.TypePile"); default: throw new Error(`Unexpected type ${this.type}`); } } /** * Can this Cards document be cloned in a duplicate workflow? * @type {boolean} */ get canClone() { if ( this.type === "deck" ) return true; else return this.cards.size === 0; } /* -------------------------------------------- */ /* API Methods */ /* -------------------------------------------- */ /** @inheritdoc */ static async createDocuments(data=[], context={}) { if ( context.keepEmbeddedIds === undefined ) context.keepEmbeddedIds = false; return super.createDocuments(data, context); } /* -------------------------------------------- */ /** * Deal one or more cards from this Cards document to each of a provided array of Cards destinations. * Cards are allocated from the top of the deck in cyclical order until the required number of Cards have been dealt. * @param {Cards[]} to An array of other Cards documents to which cards are dealt * @param {number} [number=1] The number of cards to deal to each other document * @param {object} [options={}] Options which modify how the deal operation is performed * @param {number} [options.how=0] How to draw, a value from CONST.CARD_DRAW_MODES * @param {object} [options.updateData={}] Modifications to make to each Card as part of the deal operation, * for example the displayed face * @param {string} [options.action=deal] The name of the action being performed, used as part of the dispatched * Hook event * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred * @returns {Promise} This Cards document after the deal operation has completed */ async deal(to, number=1, {action="deal", how=0, updateData={}, chatNotification=true}={}) { // Validate the request if ( !to.every(d => d instanceof Cards) ) { throw new Error("You must provide an array of Cards documents as the destinations for the Cards#deal operation"); } // Draw from the sorted stack const total = number * to.length; const drawn = this._drawCards(total, how); // Allocate cards to each destination const toCreate = to.map(() => []); const toUpdate = []; const toDelete = []; for ( let i=0; i { return cards.createEmbeddedDocuments("Card", toCreate[i], {keepId: true}); }); promises.push(this.updateEmbeddedDocuments("Card", toUpdate)); promises.push(this.deleteEmbeddedDocuments("Card", toDelete)); await Promise.all(promises); // Dispatch chat notification if ( chatNotification ) { const chatActions = { deal: "CARDS.NotifyDeal", pass: "CARDS.NotifyPass" }; this._postChatNotification(this, chatActions[action], {number, link: to.map(t => t.link).join(", ")}); } return this; } /* -------------------------------------------- */ /** * Pass an array of specific Card documents from this document to some other Cards stack. * @param {Cards} to Some other Cards document that is the destination for the pass operation * @param {string[]} ids The embedded Card ids which should be passed * @param {object} [options={}] Additional options which modify the pass operation * @param {object} [options.updateData={}] Modifications to make to each Card as part of the pass operation, * for example the displayed face * @param {string} [options.action=pass] The name of the action being performed, used as part of the dispatched * Hook event * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred * @returns {Promise} An array of the Card embedded documents created within the destination stack */ async pass(to, ids, {updateData={}, action="pass", chatNotification=true}={}) { if ( !(to instanceof Cards) ) { throw new Error("You must provide a Cards document as the recipient for the Cards#pass operation"); } // Allocate cards to different required operations const toCreate = []; const toUpdate = []; const fromUpdate = []; const fromDelete = []; // Validate the provided cards for ( let id of ids ) { const card = this.cards.get(id, {strict: true}); const deletedFromOrigin = card.origin && !card.origin.cards.get(id); // Prevent drawing cards from decks multiple times if ( (this.type === "deck") && card.isHome && card.drawn ) { throw new Error(`You may not pass Card ${id} which has already been drawn`); } // Return drawn cards to their origin deck if ( (card.origin === to) && !deletedFromOrigin ) { toUpdate.push({_id: card.id, drawn: false}); } // Create cards in a new destination else { const createData = foundry.utils.mergeObject(card.toObject(), updateData); const copyCard = (card.isHome && (to.type === "deck")); if ( copyCard ) createData.origin = to.id; else if ( card.isHome || !createData.origin ) createData.origin = this.id; createData.drawn = !copyCard && !deletedFromOrigin; toCreate.push(createData); } // Update cards in their home deck if ( card.isHome && (to.type !== "deck") ) fromUpdate.push({_id: card.id, drawn: true}); // Remove cards from their current stack else if ( !card.isHome ) fromDelete.push(card.id); } const allowed = Hooks.call("passCards", this, to, {action, toCreate, toUpdate, fromUpdate, fromDelete}); if ( allowed === false ) { console.debug(`${vtt} | The Cards#pass operation was prevented by a hooked function`); return []; } // Perform database operations const created = to.createEmbeddedDocuments("Card", toCreate, {keepId: true}); await Promise.all([ created, to.updateEmbeddedDocuments("Card", toUpdate), this.updateEmbeddedDocuments("Card", fromUpdate), this.deleteEmbeddedDocuments("Card", fromDelete) ]); // Dispatch chat notification if ( chatNotification ) { const chatActions = { pass: "CARDS.NotifyPass", play: "CARDS.NotifyPlay", discard: "CARDS.NotifyDiscard", draw: "CARDS.NotifyDraw" }; const chatFrom = action === "draw" ? to : this; const chatTo = action === "draw" ? this : to; this._postChatNotification(chatFrom, chatActions[action], {number: ids.length, link: chatTo.link}); } return created; } /* -------------------------------------------- */ /** * Draw one or more cards from some other Cards document. * @param {Cards} from Some other Cards document from which to draw * @param {number} [number=1] The number of cards to draw * @param {object} [options={}] Options which modify how the draw operation is performed * @param {number} [options.how=0] How to draw, a value from CONST.CARD_DRAW_MODES * @param {object} [options.updateData={}] Modifications to make to each Card as part of the draw operation, * for example the displayed face * @returns {Promise} An array of the Card documents which were drawn */ async draw(from, number=1, {how=0, updateData={}, ...options}={}) { if ( !(from instanceof Cards) || (from === this) ) { throw new Error("You must provide some other Cards document as the source for the Cards#draw operation"); } const toDraw = from._drawCards(number, how); return from.pass(this, toDraw.map(c => c.id), {updateData, action: "draw", ...options}); } /* -------------------------------------------- */ /** * Shuffle this Cards stack, randomizing the sort order of all the cards it contains. * @param {object} [options={}] Options which modify how the shuffle operation is performed. * @param {object} [options.updateData={}] Modifications to make to each Card as part of the shuffle operation, * for example the displayed face. * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred * @returns {Promise} The Cards document after the shuffle operation has completed */ async shuffle({updateData={}, chatNotification=true}={}) { const order = this.cards.map(c => [foundry.dice.MersenneTwister.random(), c]); order.sort((a, b) => a[0] - b[0]); const toUpdate = order.map((x, i) => { const card = x[1]; return foundry.utils.mergeObject({_id: card.id, sort: i}, updateData); }); // Post a chat notification and return await this.updateEmbeddedDocuments("Card", toUpdate); if ( chatNotification ) { this._postChatNotification(this, "CARDS.NotifyShuffle", {link: this.link}); } return this; } /* -------------------------------------------- */ /** * Recall the Cards stack, retrieving all original cards from other stacks where they may have been drawn if this is a * deck, otherwise returning all the cards in this stack to the decks where they originated. * @param {object} [options={}] Options which modify the recall operation * @param {object} [options.updateData={}] Modifications to make to each Card as part of the recall operation, * for example the displayed face * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred * @returns {Promise} The Cards document after the recall operation has completed. */ async recall(options) { if ( this.type === "deck" ) return this._resetDeck(options); return this._resetStack(options); } /* -------------------------------------------- */ /** * Perform a reset operation for a deck, retrieving all original cards from other stacks where they may have been * drawn. * @param {object} [options={}] Options which modify the reset operation. * @param {object} [options.updateData={}] Modifications to make to each Card as part of the reset operation * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred * @returns {Promise} The Cards document after the reset operation has completed. * @private */ async _resetDeck({updateData={}, chatNotification=true}={}) { // Recover all cards which belong to this stack for ( let cards of game.cards ) { if ( cards === this ) continue; const toDelete = []; for ( let c of cards.cards ) { if ( c.origin === this ) { toDelete.push(c.id); } } if ( toDelete.length ) await cards.deleteEmbeddedDocuments("Card", toDelete); } // Mark all cards as not drawn const cards = this.cards.contents; cards.sort(this.sortStandard.bind(this)); const toUpdate = cards.map(card => { return foundry.utils.mergeObject({_id: card.id, drawn: false}, updateData); }); // Post a chat notification and return await this.updateEmbeddedDocuments("Card", toUpdate); if ( chatNotification ) { this._postChatNotification(this, "CARDS.NotifyReset", {link: this.link}); } return this; } /* -------------------------------------------- */ /** * Return all cards in this stack to their original decks. * @param {object} [options={}] Options which modify the return operation. * @param {object} [options.updateData={}] Modifications to make to each Card as part of the return operation * @param {boolean} [options.chatNotification=true] Create a ChatMessage which notifies that this action has occurred * @returns {Promise} The Cards document after the return operation has completed. * @private */ async _resetStack({updateData={}, chatNotification=true}={}) { // Allocate cards to different required operations. const toUpdate = {}; const fromDelete = []; for ( const card of this.cards ) { if ( card.isHome || !card.origin ) continue; // Return drawn cards to their origin deck if ( card.origin.cards.get(card.id) ) { if ( !toUpdate[card.origin.id] ) toUpdate[card.origin.id] = []; const update = foundry.utils.mergeObject(updateData, {_id: card.id, drawn: false}, {inplace: false}); toUpdate[card.origin.id].push(update); } // Remove cards from the current stack. fromDelete.push(card.id); } const allowed = Hooks.call("returnCards", this, fromDelete.map(id => this.cards.get(id)), {toUpdate, fromDelete}); if ( allowed === false ) { console.debug(`${vtt} | The Cards#return operation was prevented by a hooked function.`); return this; } // Perform database operations. const updates = Object.entries(toUpdate).map(([origin, u]) => { return game.cards.get(origin).updateEmbeddedDocuments("Card", u); }); await Promise.all([...updates, this.deleteEmbeddedDocuments("Card", fromDelete)]); // Dispatch chat notification if ( chatNotification ) this._postChatNotification(this, "CARDS.NotifyReturn", {link: this.link}); return this; } /* -------------------------------------------- */ /** * A sorting function that is used to determine the standard order of Card documents within an un-shuffled stack. * Sorting with "en" locale to ensure the same order regardless of which client sorts the deck. * @param {Card} a The card being sorted * @param {Card} b Another card being sorted against * @returns {number} * @protected */ sortStandard(a, b) { if ( (a.suit ?? "") === (b.suit ?? "") ) return ((a.value ?? -Infinity) - (b.value ?? -Infinity)) || 0; return (a.suit ?? "").compare(b.suit ?? ""); } /* -------------------------------------------- */ /** * A sorting function that is used to determine the order of Card documents within a shuffled stack. * @param {Card} a The card being sorted * @param {Card} b Another card being sorted against * @returns {number} * @protected */ sortShuffled(a, b) { return a.sort - b.sort; } /* -------------------------------------------- */ /** * An internal helper method for drawing a certain number of Card documents from this Cards stack. * @param {number} number The number of cards to draw * @param {number} how A draw mode from CONST.CARD_DRAW_MODES * @returns {Card[]} An array of drawn Card documents * @protected */ _drawCards(number, how) { // Confirm that sufficient cards are available let available = this.availableCards; if ( available.length < number ) { throw new Error(`There are not ${number} available cards remaining in Cards [${this.id}]`); } // Draw from the stack let drawn; switch ( how ) { case CONST.CARD_DRAW_MODES.FIRST: available.sort(this.sortShuffled.bind(this)); drawn = available.slice(0, number); break; case CONST.CARD_DRAW_MODES.LAST: available.sort(this.sortShuffled.bind(this)); drawn = available.slice(-number); break; case CONST.CARD_DRAW_MODES.RANDOM: const shuffle = available.map(c => [Math.random(), c]); shuffle.sort((a, b) => a[0] - b[0]); drawn = shuffle.slice(-number).map(x => x[1]); break; } return drawn; } /* -------------------------------------------- */ /** * Create a ChatMessage which provides a notification of the operation which was just performed. * Visibility of the resulting message is linked to the default roll mode selected in the chat log dropdown. * @param {Cards} source The source Cards document from which the action originated * @param {string} action The localization key which formats the chat message notification * @param {object} context Data passed to the Localization#format method for the localization key * @returns {ChatMessage} A created ChatMessage document * @private */ _postChatNotification(source, action, context) { const messageData = { style: CONST.CHAT_MESSAGE_STYLES.OTHER, speaker: {user: game.user}, content: `
${source.name}

${game.i18n.format(action, context)}

` }; ChatMessage.applyRollMode(messageData, game.settings.get("core", "rollMode")); return ChatMessage.implementation.create(messageData); } /* -------------------------------------------- */ /* Event Listeners and Handlers */ /* -------------------------------------------- */ /** @inheritDoc */ async _preCreate(data, options, user) { const allowed = await super._preCreate(data, options, user); if ( allowed === false ) return false; for ( const card of this.cards ) { card.updateSource({drawn: false}); } } /* -------------------------------------------- */ /** @inheritDoc */ _onUpdate(changed, options, userId) { if ( "type" in changed ) { this.sheet?.close(); this._sheet = undefined; } super._onUpdate(changed, options, userId); } /* -------------------------------------------- */ /** @inheritDoc */ async _preDelete(options, user) { await this.recall(); return super._preDelete(options, user); } /* -------------------------------------------- */ /* Interaction Dialogs */ /* -------------------------------------------- */ /** * Display a dialog which prompts the user to deal cards to some number of hand-type Cards documents. * @see {@link Cards#deal} * @returns {Promise} */ async dealDialog() { const hands = game.cards.filter(c => (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED")); if ( !hands.length ) { ui.notifications.warn("CARDS.DealWarnNoTargets", {localize: true}); return this; } // Construct the dialog HTML const html = await renderTemplate("templates/cards/dialog-deal.html", { hands: hands, modes: { [CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop", [CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom", [CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom" } }); // Display the prompt return Dialog.prompt({ title: game.i18n.localize("CARDS.DealTitle"), label: game.i18n.localize("CARDS.Deal"), content: html, callback: html => { const form = html.querySelector("form.cards-dialog"); const fd = new FormDataExtended(form).object; if ( !fd.to ) return this; const toIds = fd.to instanceof Array ? fd.to : [fd.to]; const to = toIds.reduce((arr, id) => { const c = game.cards.get(id); if ( c ) arr.push(c); return arr; }, []); const options = {how: fd.how, updateData: fd.down ? {face: null} : {}}; return this.deal(to, fd.number, options).catch(err => { ui.notifications.error(err.message); return this; }); }, rejectClose: false, options: {jQuery: false} }); } /* -------------------------------------------- */ /** * Display a dialog which prompts the user to draw cards from some other deck-type Cards documents. * @see {@link Cards#draw} * @returns {Promise} */ async drawDialog() { const decks = game.cards.filter(c => (c.type === "deck") && c.testUserPermission(game.user, "LIMITED")); if ( !decks.length ) { ui.notifications.warn("CARDS.DrawWarnNoSources", {localize: true}); return []; } // Construct the dialog HTML const html = await renderTemplate("templates/cards/dialog-draw.html", { decks: decks, modes: { [CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop", [CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom", [CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom" } }); // Display the prompt return Dialog.prompt({ title: game.i18n.localize("CARDS.DrawTitle"), label: game.i18n.localize("CARDS.Draw"), content: html, callback: html => { const form = html.querySelector("form.cards-dialog"); const fd = new FormDataExtended(form).object; const from = game.cards.get(fd.from); const options = {how: fd.how, updateData: fd.down ? {face: null} : {}}; return this.draw(from, fd.number, options).catch(err => { ui.notifications.error(err.message); return []; }); }, rejectClose: false, options: {jQuery: false} }); } /* -------------------------------------------- */ /** * Display a dialog which prompts the user to pass cards from this document to some other Cards document. * @see {@link Cards#deal} * @returns {Promise} */ async passDialog() { const cards = game.cards.filter(c => (c !== this) && (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED")); if ( !cards.length ) { ui.notifications.warn("CARDS.PassWarnNoTargets", {localize: true}); return this; } // Construct the dialog HTML const html = await renderTemplate("templates/cards/dialog-pass.html", { cards: cards, modes: { [CONST.CARD_DRAW_MODES.TOP]: "CARDS.DrawModeTop", [CONST.CARD_DRAW_MODES.BOTTOM]: "CARDS.DrawModeBottom", [CONST.CARD_DRAW_MODES.RANDOM]: "CARDS.DrawModeRandom" } }); // Display the prompt return Dialog.prompt({ title: game.i18n.localize("CARDS.PassTitle"), label: game.i18n.localize("CARDS.Pass"), content: html, callback: html => { const form = html.querySelector("form.cards-dialog"); const fd = new FormDataExtended(form).object; const to = game.cards.get(fd.to); const options = {action: "pass", how: fd.how, updateData: fd.down ? {face: null} : {}}; return this.deal([to], fd.number, options).catch(err => { ui.notifications.error(err.message); return this; }); }, rejectClose: false, options: {jQuery: false} }); } /* -------------------------------------------- */ /** * Display a dialog which prompts the user to play a specific Card to some other Cards document * @see {@link Cards#pass} * @param {Card} card The specific card being played as part of this dialog * @returns {Promise} */ async playDialog(card) { const cards = game.cards.filter(c => (c !== this) && (c.type !== "deck") && c.testUserPermission(game.user, "LIMITED")); if ( !cards.length ) { ui.notifications.warn("CARDS.PassWarnNoTargets", {localize: true}); return []; } // Construct the dialog HTML const html = await renderTemplate("templates/cards/dialog-play.html", {card, cards}); // Display the prompt return Dialog.prompt({ title: game.i18n.localize("CARD.Play"), label: game.i18n.localize("CARD.Play"), content: html, callback: html => { const form = html.querySelector("form.cards-dialog"); const fd = new FormDataExtended(form).object; const to = game.cards.get(fd.to); const options = {action: "play", updateData: fd.down ? {face: null} : {}}; return this.pass(to, [card.id], options).catch(err => { ui.notifications.error(err.message); return []; }); }, rejectClose: false, options: {jQuery: false} }); } /* -------------------------------------------- */ /** * Display a confirmation dialog for whether or not the user wishes to reset a Cards stack * @see {@link Cards#recall} * @returns {Promise} */ async resetDialog() { return Dialog.confirm({ title: game.i18n.localize("CARDS.Reset"), content: `

${game.i18n.format(`CARDS.${this.type === "deck" ? "Reset" : "Return"}Confirm`, {name: this.name})}

`, yes: () => this.recall() }); } /* -------------------------------------------- */ /** @inheritdoc */ async deleteDialog(options={}) { if ( !this.drawnCards.length ) return super.deleteDialog(options); const type = this.typeLabel; return new Promise(resolve => { const dialog = new Dialog({ title: `${game.i18n.format("DOCUMENT.Delete", {type})}: ${this.name}`, content: `

${game.i18n.localize("CARDS.DeleteCannot")}

${game.i18n.format("CARDS.DeleteMustReset", {type})}

`, buttons: { reset: { icon: '', label: game.i18n.localize("CARDS.DeleteReset"), callback: () => resolve(this.delete()) }, cancel: { icon: '', label: game.i18n.localize("Cancel"), callback: () => resolve(false) } }, close: () => resolve(null), default: "reset" }, options); dialog.render(true); }); } /* -------------------------------------------- */ /** @override */ static async createDialog(data={}, {parent=null, pack=null, types, ...options}={}) { if ( types ) { if ( types.length === 0 ) throw new Error("The array of sub-types to restrict to must not be empty"); for ( const type of types ) { if ( !this.TYPES.includes(type) ) throw new Error(`Invalid ${this.documentName} sub-type: "${type}"`); } } // Collect data const documentTypes = this.TYPES.filter(t => types?.includes(t) !== false); let collection; if ( !parent ) { if ( pack ) collection = game.packs.get(pack); else collection = game.collections.get(this.documentName); } const folders = collection?._formatFolderSelectOptions() ?? []; const label = game.i18n.localize(this.metadata.label); const title = game.i18n.format("DOCUMENT.Create", {type: label}); const type = data.type || documentTypes[0]; // Render the document creation form const html = await renderTemplate("templates/sidebar/cards-create.html", { folders, name: data.name || "", defaultName: this.implementation.defaultName({type, parent, pack}), folder: data.folder, hasFolders: folders.length >= 1, type, types: Object.fromEntries(documentTypes.map(type => { const label = CONFIG[this.documentName]?.typeLabels?.[type]; return [type, label && game.i18n.has(label) ? game.i18n.localize(label) : type]; }).sort((a, b) => a[1].localeCompare(b[1], game.i18n.lang))), hasTypes: true, presets: CONFIG.Cards.presets }); // Render the confirmation dialog window return Dialog.prompt({ title: title, content: html, label: title, render: html => { html[0].querySelector('[name="type"]').addEventListener("change", e => { html[0].querySelector('[name="name"]').placeholder = this.implementation.defaultName( {type: e.target.value, parent, pack}); }); }, callback: async html => { const form = html[0].querySelector("form"); const fd = new FormDataExtended(form); foundry.utils.mergeObject(data, fd.object, {inplace: true}); if ( !data.folder ) delete data.folder; if ( !data.name?.trim() ) data.name = this.implementation.defaultName({type: data.type, parent, pack}); const preset = CONFIG.Cards.presets[data.preset]; if ( preset && (preset.type === data.type) ) { const presetData = await fetch(preset.src).then(r => r.json()); data = foundry.utils.mergeObject(presetData, data); } return this.implementation.create(data, {parent, pack, renderSheet: true}); }, rejectClose: false, options }); } }