497 lines
14 KiB
JavaScript
497 lines
14 KiB
JavaScript
/* global TokenDocument */
|
|
|
|
import DG from "../config/index.js";
|
|
import { getCharacterSheetThemeClass } from "../applications/dg-dialog.js";
|
|
|
|
const CHAT_CARD_TEMPLATE = "systems/deltagreen/templates/chat/dg-chat-card.hbs";
|
|
const { renderTemplate } = foundry.applications.handlebars;
|
|
|
|
/**
|
|
* Resolve a TokenDocument from sheet/macro context.
|
|
* @param {TokenDocument|object|string|null} token
|
|
* @param {Scene|SceneDocument|string|null} [scene]
|
|
* @returns {TokenDocument|null}
|
|
*/
|
|
export function resolveDGTokenDocument(token, scene = null) {
|
|
if (!token) return null;
|
|
if (token.documentName === "Token" || token instanceof TokenDocument) {
|
|
return token;
|
|
}
|
|
if (token.document?.documentName === "Token") {
|
|
return token.document;
|
|
}
|
|
const tokenId = typeof token === "string" ? token : token.id ?? token._id;
|
|
if (!tokenId) return null;
|
|
|
|
const sceneId =
|
|
typeof scene === "string"
|
|
? scene
|
|
: scene?.id ?? token.parent?.id ?? canvas.scene?.id;
|
|
const sceneDoc = sceneId ? game.scenes.get(sceneId) : canvas.scene;
|
|
return (
|
|
sceneDoc?.tokens.get(tokenId) ??
|
|
canvas.tokens.get(tokenId)?.document ??
|
|
null
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Speaker for Delta Green chat; uses token name for unlinked tokens.
|
|
* @param {object} params
|
|
* @param {Actor|null} [params.actor]
|
|
* @param {TokenDocument|object|string|null} [params.token]
|
|
* @param {Scene|SceneDocument|string|null} [params.scene]
|
|
* @returns {ChatMessage.SpeakerData}
|
|
*/
|
|
export function getDGSpeaker({
|
|
actor = null,
|
|
token = null,
|
|
scene = null,
|
|
} = {}) {
|
|
const tokenDoc = resolveDGTokenDocument(token, scene);
|
|
const sceneDoc = scene ?? tokenDoc?.parent ?? canvas.scene ?? null;
|
|
const alias = tokenDoc?.name ?? actor?.name;
|
|
|
|
return ChatMessage.getSpeaker({
|
|
actor,
|
|
token: tokenDoc,
|
|
scene: sceneDoc,
|
|
alias,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Token document from an existing chat speaker payload.
|
|
* @param {ChatMessage.SpeakerData|null} speaker
|
|
* @returns {TokenDocument|null}
|
|
*/
|
|
export function getTokenFromChatSpeaker(speaker) {
|
|
if (!speaker?.token) return null;
|
|
return resolveDGTokenDocument(speaker.token, speaker.scene);
|
|
}
|
|
|
|
/**
|
|
* Best token for a roll from sheet or macro context.
|
|
* @param {Actor|null} actor
|
|
* @param {TokenDocument|object|string|null} [sheetToken]
|
|
* @returns {TokenDocument|null}
|
|
*/
|
|
export function getDGRollToken(actor, sheetToken = null) {
|
|
const fromSheet = resolveDGTokenDocument(sheetToken);
|
|
if (fromSheet) return fromSheet;
|
|
|
|
if (!actor) return null;
|
|
|
|
const controlled = canvas.tokens?.controlled?.find((t) => t.actor === actor);
|
|
if (controlled?.document) return controlled.document;
|
|
|
|
const active = actor.getActiveTokens?.(true, true)?.[0];
|
|
return active ?? null;
|
|
}
|
|
|
|
/**
|
|
* @param {ChatMessage.SpeakerData|null} speaker
|
|
* @returns {string}
|
|
*/
|
|
export function getDGChatSpeakerName(speaker) {
|
|
if (!speaker) {
|
|
return game.i18n.localize("CHAT.UnknownUser");
|
|
}
|
|
return speaker.alias ?? game.i18n.localize("CHAT.UnknownUser");
|
|
}
|
|
|
|
/**
|
|
* Portrait image for a Delta Green chat card.
|
|
* @param {object} params
|
|
* @param {Actor|null} [params.actor]
|
|
* @param {TokenDocument|object|string|null} [params.token]
|
|
* @param {ChatMessage.SpeakerData|null} [params.speaker]
|
|
* @returns {string}
|
|
*/
|
|
export function getDGChatPortraitSrc({
|
|
actor = null,
|
|
token = null,
|
|
speaker = null,
|
|
} = {}) {
|
|
const tokenDoc =
|
|
resolveDGTokenDocument(token) ?? getTokenFromChatSpeaker(speaker);
|
|
const actorDoc =
|
|
actor ?? (speaker ? ChatMessage.getSpeakerActor(speaker) : null);
|
|
return tokenDoc?.texture?.src ?? actorDoc?.img ?? "icons/svg/mystery-man.svg";
|
|
}
|
|
|
|
/**
|
|
* @param {object} params
|
|
* @param {string} [params.title]
|
|
* @param {string} [params.subtitle]
|
|
* @param {string} [params.label] Backward-compat fallback treated as title
|
|
* @param {string} [params.flavor] Foundry flavor fallback treated as title
|
|
* @param {string} [params.rollLabel] Explicit skill/target line below the card header
|
|
* @returns {{ rollLabel: string }}
|
|
*/
|
|
function normalizeChatCardHeader({
|
|
title = "",
|
|
subtitle = "",
|
|
label = "",
|
|
flavor = "",
|
|
rollLabel = "",
|
|
} = {}) {
|
|
if (rollLabel) return { rollLabel };
|
|
const resolvedTitle = title || label || flavor;
|
|
if (resolvedTitle && subtitle) {
|
|
return { rollLabel: `${resolvedTitle}: ${subtitle}` };
|
|
}
|
|
return { rollLabel: resolvedTitle };
|
|
}
|
|
|
|
/**
|
|
* @param {object} params
|
|
* @param {string} [params.title]
|
|
* @param {string} [params.subtitle]
|
|
* @param {string} [params.label]
|
|
* @param {string} [params.flavor]
|
|
* @param {string} [params.rollLabel]
|
|
* @returns {boolean}
|
|
*/
|
|
function shouldUseChatCard({
|
|
title = "",
|
|
subtitle = "",
|
|
label = "",
|
|
flavor = "",
|
|
rollLabel = "",
|
|
} = {}) {
|
|
return Boolean(
|
|
normalizeChatCardHeader({ title, subtitle, label, flavor, rollLabel })
|
|
.rollLabel,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {ChatMessage} message
|
|
* @param {HTMLElement} element
|
|
*/
|
|
export function enrichDGChatCardMessage(message, element) {
|
|
const card = element.querySelector(".dg-chat-card");
|
|
if (!card) return;
|
|
|
|
card.classList.remove("program-style", "cowboy-style", "outlaw-style");
|
|
card.classList.add(getCharacterSheetThemeClass());
|
|
|
|
const foundryHeader = element.querySelector(".message-header");
|
|
const header = card.querySelector(".dg-chat-card__header");
|
|
|
|
let portrait = card.querySelector(".dg-chat-card__portrait");
|
|
if (!portrait) {
|
|
portrait = card.querySelector(".dg-chat-card__icon");
|
|
if (portrait) portrait.classList.add("dg-chat-card__portrait");
|
|
}
|
|
if (portrait) {
|
|
portrait.src = getDGChatPortraitSrc({ speaker: message.speaker });
|
|
}
|
|
|
|
const speakerEl = card.querySelector(".dg-chat-card__speaker");
|
|
const authorEl = card.querySelector(".dg-chat-card__author");
|
|
const speakerName = message.alias ?? "";
|
|
const authorName = message.author?.name ?? "";
|
|
|
|
if (speakerEl) speakerEl.textContent = speakerName;
|
|
|
|
if (authorEl) {
|
|
const whisperEl = foundryHeader?.querySelector(".whisper-to");
|
|
if (whisperEl) {
|
|
authorEl.textContent = whisperEl.textContent.trim();
|
|
authorEl.hidden = false;
|
|
whisperEl.remove();
|
|
} else if (speakerName !== authorName && authorName) {
|
|
authorEl.textContent = authorName;
|
|
authorEl.hidden = false;
|
|
} else {
|
|
authorEl.hidden = true;
|
|
}
|
|
}
|
|
|
|
let metadataSlot = card.querySelector(".dg-chat-card__metadata");
|
|
if (!metadataSlot && header) {
|
|
metadataSlot = document.createElement("span");
|
|
metadataSlot.className = "dg-chat-card__metadata";
|
|
header.appendChild(metadataSlot);
|
|
}
|
|
|
|
const foundryMetadata = foundryHeader?.querySelector(".message-metadata");
|
|
if (metadataSlot && foundryMetadata) {
|
|
metadataSlot.replaceWith(foundryMetadata);
|
|
foundryMetadata.classList.add("dg-chat-card__metadata");
|
|
}
|
|
|
|
foundryHeader?.querySelector(".flavor-text")?.remove();
|
|
}
|
|
|
|
/**
|
|
* @param {ChatMessage.SpeakerData} speaker
|
|
* @param {string} [authorId]
|
|
* @returns {{ speakerName: string, authorName: string, showAuthor: boolean }}
|
|
*/
|
|
function getDGChatCardIdentity(speaker, authorId = game.user.id) {
|
|
const speakerName = getDGChatSpeakerName(speaker);
|
|
const authorName = game.users.get(authorId)?.name ?? "";
|
|
return {
|
|
speakerName,
|
|
authorName,
|
|
showAuthor: Boolean(authorName) && speakerName !== authorName,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {object} params
|
|
* @param {string} params.portraitSrc
|
|
* @param {string} [params.rollLabel]
|
|
* @param {string} [params.speakerName]
|
|
* @param {string} [params.authorName]
|
|
* @param {boolean} [params.showAuthor]
|
|
* @param {string} params.content
|
|
* @returns {Promise<string>}
|
|
*/
|
|
export async function renderDGChatCard({
|
|
portraitSrc,
|
|
rollLabel = "",
|
|
speakerName = "",
|
|
authorName = "",
|
|
showAuthor = false,
|
|
content,
|
|
}) {
|
|
return renderTemplate(CHAT_CARD_TEMPLATE, {
|
|
portraitSrc,
|
|
rollLabel,
|
|
speakerName,
|
|
authorName,
|
|
showAuthor,
|
|
content,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Whether HTML already includes an inline dice roll block.
|
|
* @param {string} content
|
|
* @returns {boolean}
|
|
*/
|
|
function contentIncludesRollDisplay(content) {
|
|
return typeof content === "string" && content.includes("dice-roll");
|
|
}
|
|
|
|
/**
|
|
* Render roll HTML for embedding in a chat card body.
|
|
* @param {Roll[]} rolls
|
|
* @returns {Promise<string>}
|
|
*/
|
|
async function renderRollsHTML(rolls) {
|
|
let html = "";
|
|
for (const roll of rolls) {
|
|
if (!roll._evaluated) await roll.evaluate();
|
|
html += await roll.render();
|
|
}
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* Wrap message content in the Delta Green chat card shell.
|
|
* @param {object} messageData
|
|
* @param {object} [options]
|
|
* @param {string} [options.title]
|
|
* @param {string} [options.subtitle]
|
|
* @param {string} [options.label] Backward-compat fallback treated as title
|
|
* @param {Actor|null} [options.actor]
|
|
* @param {TokenDocument|null} [options.token]
|
|
* @returns {Promise<object>}
|
|
*/
|
|
export async function wrapDGChatMessageData(
|
|
messageData,
|
|
{ title, subtitle, label, rollLabel, actor = null, token = null } = {},
|
|
) {
|
|
const header = normalizeChatCardHeader({
|
|
title,
|
|
subtitle,
|
|
label: label ?? messageData.flavor,
|
|
rollLabel,
|
|
});
|
|
if (!header.rollLabel) return messageData;
|
|
|
|
const portraitSrc = getDGChatPortraitSrc({
|
|
actor,
|
|
token,
|
|
speaker: messageData.speaker,
|
|
});
|
|
const identity = getDGChatCardIdentity(
|
|
messageData.speaker,
|
|
messageData.author,
|
|
);
|
|
const wrappedContent = await renderDGChatCard({
|
|
portraitSrc,
|
|
...header,
|
|
...identity,
|
|
content: messageData.content ?? "",
|
|
});
|
|
|
|
const data = foundry.utils.deepClone(messageData);
|
|
data.content = wrappedContent;
|
|
data.flavor = "";
|
|
foundry.utils.setProperty(data, `flags.${DG.ID}.chatCard`, true);
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Build chat message data for a roll with optional Delta Green card wrapping.
|
|
*
|
|
* @param {object} params
|
|
* @param {Roll} params.roll
|
|
* @param {Actor|null} [params.actor]
|
|
* @param {TokenDocument|null} [params.token]
|
|
* @param {Scene|SceneDocument|null} [params.scene]
|
|
* @param {string} [params.title]
|
|
* @param {string} [params.subtitle]
|
|
* @param {string} [params.label]
|
|
* @param {string} [params.content]
|
|
* @param {string} [params.messageMode]
|
|
* @param {object} [params.flags]
|
|
* @returns {Promise<{ messageData: object, mappedMode: string }>}
|
|
*/
|
|
export async function prepareDGRollChatMessageData({
|
|
roll,
|
|
actor = null,
|
|
token = null,
|
|
scene = null,
|
|
title = "",
|
|
subtitle = "",
|
|
label = "",
|
|
rollLabel = "",
|
|
content,
|
|
messageMode,
|
|
flags = {},
|
|
}) {
|
|
const mode = messageMode ?? game.settings.get("core", "messageMode");
|
|
const mappedMode = foundry.dice.Roll._mapLegacyRollMode(mode);
|
|
const resolvedActor = actor ?? roll.actor ?? null;
|
|
const resolvedToken = token ?? roll.options?.token ?? null;
|
|
const useCard = shouldUseChatCard({ title, subtitle, label, rollLabel });
|
|
|
|
if (!roll._evaluated) {
|
|
await roll.evaluate({ allowInteractive: mappedMode !== "blind" });
|
|
}
|
|
|
|
const speaker = getDGSpeaker({
|
|
actor: resolvedActor,
|
|
token: resolvedToken,
|
|
scene,
|
|
});
|
|
|
|
let bodyContent = content ?? String(roll.total);
|
|
|
|
// Foundry only auto-injects roll HTML when content has no child elements.
|
|
// DG chat cards always wrap content in a <section>, so embed rolls in the body.
|
|
if (useCard && !contentIncludesRollDisplay(bodyContent)) {
|
|
bodyContent = `${bodyContent}${await renderRollsHTML([roll])}`;
|
|
}
|
|
|
|
let messageData = {
|
|
author: game.user.id,
|
|
speaker,
|
|
content: bodyContent,
|
|
sound: CONFIG.sounds.dice,
|
|
rolls: [roll],
|
|
flags: foundry.utils.mergeObject({ [DG.ID]: { chatCard: useCard } }, flags),
|
|
};
|
|
|
|
if (useCard) {
|
|
messageData = await wrapDGChatMessageData(messageData, {
|
|
title,
|
|
subtitle,
|
|
label,
|
|
rollLabel,
|
|
actor: resolvedActor,
|
|
token: resolvedToken,
|
|
});
|
|
}
|
|
|
|
return { messageData, mappedMode };
|
|
}
|
|
|
|
/**
|
|
* Create a roll chat message with the Delta Green card shell and an explicit rolls array.
|
|
* Matches the Dolmenwood/DSN pattern: styled content in `content`, Roll objects in `rolls`.
|
|
*
|
|
* @see https://gitlab.com/riccisi/foundryvtt-dice-so-nice/-/wikis/Integration
|
|
* @param {object} params
|
|
* @param {Roll} params.roll Evaluated or unevaluated roll to attach to the message.
|
|
* @param {Actor|null} [params.actor]
|
|
* @param {TokenDocument|null} [params.token]
|
|
* @param {Scene|SceneDocument|null} [params.scene]
|
|
* @param {string} [params.title]
|
|
* @param {string} [params.subtitle]
|
|
* @param {string} [params.label]
|
|
* @param {string} [params.content] Card body HTML
|
|
* @param {string} [params.messageMode]
|
|
* @param {object} [params.flags]
|
|
* @returns {Promise<ChatMessage>}
|
|
*/
|
|
export async function createDGRollChatMessage(params) {
|
|
const { messageData, mappedMode } =
|
|
await prepareDGRollChatMessageData(params);
|
|
|
|
const ChatMessageDocument = foundry.utils.getDocumentClass("ChatMessage");
|
|
const msg = new ChatMessageDocument(messageData);
|
|
msg.applyMode(mappedMode);
|
|
return ChatMessageDocument.create(msg);
|
|
}
|
|
|
|
/**
|
|
* @param {object} params
|
|
* @param {Actor} params.actor
|
|
* @param {TokenDocument|null} [params.token]
|
|
* @param {Scene|SceneDocument|null} [params.scene]
|
|
* @param {string} [params.title]
|
|
* @param {string} [params.subtitle]
|
|
* @param {string} [params.label] Backward-compat fallback treated as title
|
|
* @param {string} params.content Card body HTML
|
|
* @param {string} [params.messageMode]
|
|
* @param {object} [params.flags]
|
|
* @returns {Promise<ChatMessage>}
|
|
*/
|
|
export async function createDGChatMessage({
|
|
actor,
|
|
token = null,
|
|
scene = null,
|
|
title = "",
|
|
subtitle = "",
|
|
label = "",
|
|
rollLabel = "",
|
|
content,
|
|
messageMode,
|
|
flags = {},
|
|
}) {
|
|
const header = normalizeChatCardHeader({ title, subtitle, label, rollLabel });
|
|
if (!header.rollLabel) {
|
|
return ChatMessage.create({
|
|
speaker: getDGSpeaker({ actor, token, scene }),
|
|
content,
|
|
messageMode: messageMode ?? game.settings.get("core", "messageMode"),
|
|
flags,
|
|
});
|
|
}
|
|
|
|
const speaker = getDGSpeaker({ actor, token, scene });
|
|
const portraitSrc = getDGChatPortraitSrc({ actor, token, speaker });
|
|
const identity = getDGChatCardIdentity(speaker);
|
|
const wrappedContent = await renderDGChatCard({
|
|
portraitSrc,
|
|
...header,
|
|
...identity,
|
|
content,
|
|
});
|
|
|
|
return ChatMessage.create({
|
|
speaker,
|
|
content: wrappedContent,
|
|
messageMode: messageMode ?? game.settings.get("core", "messageMode"),
|
|
flags: foundry.utils.mergeObject({ [DG.ID]: { chatCard: true } }, flags),
|
|
});
|
|
}
|