926 lines
30 KiB
JavaScript
926 lines
30 KiB
JavaScript
/**
|
||
* Donjon & Cie - Systeme FoundryVTT
|
||
*
|
||
* Donjon & Cie est un jeu de role edite par John Doe.
|
||
* Ce systeme FoundryVTT est une implementation independante et n'est pas
|
||
* affilie a John Doe.
|
||
*
|
||
* @author LeRatierBretonnien
|
||
* @copyright 2025–2026 LeRatierBretonnien
|
||
* @license CC BY-NC-SA 4.0 – https://creativecommons.org/licenses/by-nc-sa/4.0/
|
||
*/
|
||
|
||
import { DonjonEtCieUtility } from "./donjon-et-cie-utility.mjs";
|
||
|
||
export class DonjonEtCieMacros {
|
||
static MISSION_PACK_MODE_SETTING = "missionPackMode";
|
||
static MISSION_PACK_SOCKET_SCOPE = "missionPackCampaign";
|
||
static MISSION_PACK_REQUEST_TIMEOUT = 300000;
|
||
static MISSION_PACK_CAMPAIGN_DICE = [20, 12, 10, 8];
|
||
static MISSION_PACK_STEWARD_MODES = {
|
||
positive: "avantage",
|
||
neutral: "normal",
|
||
negative: "desavantage"
|
||
};
|
||
static #campaignRequests = new Map();
|
||
static #socketRegistered = false;
|
||
|
||
static MISSION_PACK_TABLES = [
|
||
{ key: "melee", name: "Armes de corps a corps", multiple: false },
|
||
{ key: "ranged", name: "Armes a distance", multiple: false },
|
||
{ key: "armor", name: "Armures", multiple: false },
|
||
{ key: "misc", name: "Encas et equipement divers", multiple: true }
|
||
];
|
||
|
||
static MISSION_PACK_UNIQUE_ITEMS = [
|
||
{
|
||
name: "L epee de Monsieur Noir",
|
||
type: "arme",
|
||
system: {
|
||
categorie: "melee",
|
||
caracteristique: "force",
|
||
degats: "1d6",
|
||
portee: "",
|
||
mains: 1,
|
||
equipee: false,
|
||
description: "<p>Elle n a rien de particulier mais confere un prestige important.</p><p>Avantage a tous les jets d interaction avec les employes de Donjon & Cie.</p>",
|
||
notes: ""
|
||
}
|
||
},
|
||
{
|
||
name: "Fil a plomb d Arnezon",
|
||
type: "equipement",
|
||
system: {
|
||
quantite: 1,
|
||
equipee: false,
|
||
emplacement: "",
|
||
description: "<p>Il oscille de maniere clairement etrange en presence d un passage secret.</p>",
|
||
notes: ""
|
||
}
|
||
},
|
||
{
|
||
name: "Boussole de Drize Durban",
|
||
type: "equipement",
|
||
system: {
|
||
quantite: 1,
|
||
equipee: false,
|
||
emplacement: "",
|
||
description: "<p>Trois reglages : pointe vers le client, le resident ou l employe le plus proche.</p>",
|
||
notes: ""
|
||
}
|
||
},
|
||
{
|
||
name: "Boule de cristal de la supervision",
|
||
type: "equipement",
|
||
system: {
|
||
quantite: 1,
|
||
equipee: false,
|
||
emplacement: "",
|
||
description: "<p>Permet de voir toute personne ou lieu qu on connait deja.</p><p>Jet de SAG pour la controler. La plupart des huiles sentent quand on les observe.</p>",
|
||
notes: ""
|
||
}
|
||
},
|
||
{
|
||
name: "Dent du dragon Leogradonardicus III",
|
||
type: "equipement",
|
||
system: {
|
||
quantite: 1,
|
||
equipee: false,
|
||
emplacement: "",
|
||
description: "<p>Un jet de CHA reussi permet de controler les reptiles inintelligents et de charmer les humanoides reptiliens.</p>",
|
||
notes: ""
|
||
}
|
||
},
|
||
{
|
||
name: "Doigt d Aarcarcerax",
|
||
type: "consommable",
|
||
system: {
|
||
quantite: 1,
|
||
delta: 4,
|
||
effet: "Tue la creature vers qui on pointe le doigt.",
|
||
description: "<p>Tue la creature vers qui on pointe le doigt.</p><p>La liche sait instantanement qu on a retrouve son doigt.</p>",
|
||
notes: ""
|
||
}
|
||
},
|
||
{
|
||
name: "Cape de Vlad von Drakovitch",
|
||
type: "consommable",
|
||
system: {
|
||
quantite: 1,
|
||
delta: 6,
|
||
effet: "Permet de se transformer en 1-3 rats, 4-5 chauve-souris, 6 forme gazeuse.",
|
||
description: "<p>Delta 6 charges.</p><p>Permet de se transformer en 1-3 rats, 4-5 chauve-souris, 6 forme gazeuse.</p>",
|
||
notes: ""
|
||
}
|
||
},
|
||
{
|
||
name: "Vieux carnet de notes d Affalella",
|
||
type: "consommable",
|
||
system: {
|
||
quantite: 1,
|
||
delta: 6,
|
||
effet: "Fonctionne comme des faveurs de la Mercatique utilisables uniquement dans les aires client.",
|
||
description: "<p>Vieux carnet de notes d Affalella, directrice de la Mercatique.</p><p>Fonctionne comme Delta 6 faveurs de la Mercatique utilisables uniquement dans les aires client.</p>",
|
||
notes: ""
|
||
}
|
||
},
|
||
{
|
||
name: "Ancienne cle universelle de Paiji",
|
||
type: "consommable",
|
||
system: {
|
||
quantite: 1,
|
||
delta: 8,
|
||
effet: "Ouvre tous les coffres, portes, armoires et autres serrures du Donjon.",
|
||
description: "<p>Ancienne cle universelle de Paiji, directeur de la Maintenance.</p><p>Delta 8 usages. Ouvre tous les coffres, portes, armoires et autres serrures du Donjon.</p>",
|
||
notes: ""
|
||
}
|
||
},
|
||
{
|
||
name: "Baguette d urgence",
|
||
type: "equipement",
|
||
system: {
|
||
quantite: 1,
|
||
equipee: false,
|
||
emplacement: "",
|
||
description: "<p>Teleporte l utilisateur au palier des huiles lorsque la baguette est brisee.</p>",
|
||
notes: ""
|
||
}
|
||
}
|
||
];
|
||
|
||
static registerSocketListeners() {
|
||
if (this.#socketRegistered || !game.socket) return;
|
||
|
||
game.socket.on(`system.${game.system.id}`, (payload) => {
|
||
void this.#handleSocketMessage(payload);
|
||
});
|
||
|
||
this.#socketRegistered = true;
|
||
}
|
||
|
||
static #normalizeName(value) {
|
||
return String(value ?? "")
|
||
.normalize("NFD")
|
||
.replace(/\p{Diacritic}/gu, "")
|
||
.replace(/['’]/g, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim()
|
||
.toLowerCase();
|
||
}
|
||
|
||
static #getMissionPackLabel(key) {
|
||
return game.i18n.localize(`DNC.Macro.MissionPack.${key}`);
|
||
}
|
||
|
||
static #getModeLabel(mode) {
|
||
if (mode === "avantage") return game.i18n.localize("DNC.UI.ModeAdvantage");
|
||
if (mode === "desavantage") return game.i18n.localize("DNC.UI.ModeDisadvantage");
|
||
return game.i18n.localize("DNC.UI.ModeNormal");
|
||
}
|
||
|
||
static #getMissionPackMode() {
|
||
return String(game.settings.get("fvtt-donjon-et-cie", this.MISSION_PACK_MODE_SETTING) ?? "classic");
|
||
}
|
||
|
||
static #getMissionPackModeLabel(mode) {
|
||
return game.i18n.localize(mode === "campaign"
|
||
? "DNC.Macro.MissionPack.ModeCampaign"
|
||
: "DNC.Macro.MissionPack.ModeClassic");
|
||
}
|
||
|
||
static #getMissionPackModeDescription(mode) {
|
||
return game.i18n.localize(mode === "campaign"
|
||
? "DNC.Macro.MissionPack.ModeCampaignHint"
|
||
: "DNC.Macro.MissionPack.ModeClassicHint");
|
||
}
|
||
|
||
static #getStewardRelationLabel(relation) {
|
||
return game.i18n.localize(`DNC.Macro.MissionPack.Relation.${relation ?? "neutral"}`);
|
||
}
|
||
|
||
static #getStewardRelationOptions() {
|
||
return ["positive", "neutral", "negative"].map((relation) => ({
|
||
value: relation,
|
||
label: this.#getStewardRelationLabel(relation)
|
||
}));
|
||
}
|
||
|
||
static #getCampaignDiceOptions() {
|
||
return this.MISSION_PACK_CAMPAIGN_DICE.map((sides) => ({
|
||
value: String(sides),
|
||
label: `1d${sides}`
|
||
}));
|
||
}
|
||
|
||
static #getDefaultCampaignAssignments() {
|
||
return Object.fromEntries(this.MISSION_PACK_TABLES.map((spec, index) => [spec.key, String(this.MISSION_PACK_CAMPAIGN_DICE[index])]));
|
||
}
|
||
|
||
static #getDefaultMissionPackActorId(actors) {
|
||
const controlledActor = canvas?.tokens?.controlled?.[0]?.actor ?? null;
|
||
if (controlledActor?.type === "employe") return controlledActor.id;
|
||
return actors[0]?.id ?? "";
|
||
}
|
||
|
||
static #getMissionPackActorOptions() {
|
||
return game.actors
|
||
.filter((actor) => actor.type === "employe")
|
||
.sort((a, b) => a.name.localeCompare(b.name, "fr", { sensitivity: "base" }))
|
||
.map((actor) => ({
|
||
value: actor.id,
|
||
label: actor.name
|
||
}));
|
||
}
|
||
|
||
static #getMissionPackOwnerUsers(actor) {
|
||
return game.users
|
||
.filter((user) => !user.isGM && actor.testUserPermission(user, CONST.DOCUMENT_OWNERSHIP_LEVELS.OWNER))
|
||
.sort((a, b) => Number(b.active) - Number(a.active) || a.name.localeCompare(b.name, "fr", { sensitivity: "base" }));
|
||
}
|
||
|
||
static #getCampaignTargetUser(actor) {
|
||
return this.#getMissionPackOwnerUsers(actor).find((user) => user.active) ?? null;
|
||
}
|
||
|
||
static async #resolveMissionPackActor(target = null) {
|
||
if (target?.documentName === "Actor") return target;
|
||
if (target?.actor?.documentName === "Actor") return target.actor;
|
||
if (typeof target === "string" && target) {
|
||
const document = await fromUuid(target);
|
||
if (document?.documentName === "Actor") return document;
|
||
if (document?.actor?.documentName === "Actor") return document.actor;
|
||
}
|
||
|
||
const controlledTokens = canvas?.tokens?.controlled ?? [];
|
||
if (controlledTokens.length === 1 && controlledTokens[0]?.actor) return controlledTokens[0].actor;
|
||
if (controlledTokens.length > 1) {
|
||
ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnMultipleTokens"));
|
||
return null;
|
||
}
|
||
|
||
return game.user.character ?? null;
|
||
}
|
||
|
||
static async #getRandomTableDocuments() {
|
||
const worldTables = game.tables?.contents ?? [];
|
||
if (worldTables.length) return worldTables;
|
||
|
||
const pack = game.packs.get("fvtt-donjon-et-cie.random-tables");
|
||
return pack ? pack.getDocuments() : [];
|
||
}
|
||
|
||
static async #findRollTableByName(name) {
|
||
const normalizedName = this.#normalizeName(name);
|
||
const worldTable = game.tables.find((table) => this.#normalizeName(table.name) === normalizedName);
|
||
if (worldTable) return worldTable;
|
||
|
||
const packTables = await this.#getRandomTableDocuments();
|
||
return packTables.find((table) => this.#normalizeName(table.name) === normalizedName) ?? null;
|
||
}
|
||
|
||
static async #getEquipmentDocuments() {
|
||
const pack = game.packs.get("fvtt-donjon-et-cie.equipment");
|
||
const packDocuments = pack ? await pack.getDocuments() : [];
|
||
return [...(game.items?.contents ?? []), ...packDocuments];
|
||
}
|
||
|
||
static async #findItemByName(name) {
|
||
const normalizedName = this.#normalizeName(name);
|
||
const documents = await this.#getEquipmentDocuments();
|
||
return documents.find((item) => this.#normalizeName(item.name) === normalizedName) ?? null;
|
||
}
|
||
|
||
static #extractUuidTargets(text) {
|
||
const matches = [...String(text ?? "").matchAll(/@UUID\[([^\]]+)\](?:\{([^}]+)\})?/g)];
|
||
return matches.map((match) => ({
|
||
uuid: match[1],
|
||
label: match[2] ?? ""
|
||
}));
|
||
}
|
||
|
||
static #extractPlainTextEntries(text) {
|
||
const rawText = String(text ?? "")
|
||
.replace(/<[^>]+>/g, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
|
||
if (!rawText) return [];
|
||
|
||
return rawText
|
||
.split(",")
|
||
.map((entry) => entry.trim())
|
||
.filter(Boolean)
|
||
.filter((entry) => !/^dotation\s+\d+$/i.test(entry));
|
||
}
|
||
|
||
static async #evaluateRoll(formula) {
|
||
const roll = await (new Roll(formula)).evaluate();
|
||
const values = roll.dice
|
||
.flatMap((die) => die.results.map((result) => Number(result.result ?? result.count ?? 0)))
|
||
.filter((value) => Number.isFinite(value));
|
||
|
||
return {
|
||
roll,
|
||
total: Number(roll.total ?? 0),
|
||
values
|
||
};
|
||
}
|
||
|
||
static #formatRollValues(values, kept) {
|
||
if (!values.length) return String(kept ?? 0);
|
||
if (values.length === 1) return String(values[0]);
|
||
return `${values.join(" / ")} -> ${kept}`;
|
||
}
|
||
|
||
static async #rollPackDie(sides, { mode = "normal" } = {}) {
|
||
const formula = mode === "avantage"
|
||
? `2d${sides}kh`
|
||
: mode === "desavantage"
|
||
? `2d${sides}kl`
|
||
: `1d${sides}`;
|
||
const evaluated = await this.#evaluateRoll(formula);
|
||
|
||
return {
|
||
sides: Number(sides),
|
||
dieLabel: `1d${sides}`,
|
||
roll: evaluated.roll,
|
||
values: evaluated.values,
|
||
valuesLabel: this.#formatRollValues(evaluated.values, evaluated.total),
|
||
kept: evaluated.total,
|
||
mode,
|
||
modeLabel: this.#getModeLabel(mode)
|
||
};
|
||
}
|
||
|
||
static #createInlineItemData(spec) {
|
||
return {
|
||
name: spec.name,
|
||
type: spec.type,
|
||
img: DonjonEtCieUtility.getDefaultItemIcon(spec.type),
|
||
system: foundry.utils.deepClone(spec.system)
|
||
};
|
||
}
|
||
|
||
static #getTableResults(table) {
|
||
return table?.results?.contents ?? [];
|
||
}
|
||
|
||
static #getTableRange(result) {
|
||
const rawRange = Array.isArray(result?.range)
|
||
? result.range
|
||
: result?._source?.range ?? [];
|
||
return [Number(rawRange[0] ?? 0), Number(rawRange[1] ?? 0)];
|
||
}
|
||
|
||
static #findTableResultByTotal(table, total) {
|
||
const entries = this.#getTableResults(table)
|
||
.map((result) => ({
|
||
result,
|
||
range: this.#getTableRange(result)
|
||
}))
|
||
.filter(({ range }) => Number.isFinite(range[0]) && Number.isFinite(range[1]) && range[1] >= range[0])
|
||
.sort((a, b) => a.range[0] - b.range[0]);
|
||
|
||
if (!entries.length) {
|
||
return {
|
||
result: null,
|
||
clampedTotal: total,
|
||
range: null
|
||
};
|
||
}
|
||
|
||
const minimum = entries[0].range[0];
|
||
const maximum = entries.at(-1).range[1];
|
||
const clampedTotal = Math.max(minimum, Math.min(maximum, total));
|
||
const match = entries.find(({ range }) => clampedTotal >= range[0] && clampedTotal <= range[1]) ?? entries.at(-1);
|
||
|
||
return {
|
||
result: match.result,
|
||
clampedTotal,
|
||
range: match.range
|
||
};
|
||
}
|
||
|
||
static async #resolveUniqueMissionPackEntry({ comparisonRoll = null } = {}) {
|
||
const reference = await this.#rollPackDie(20);
|
||
const actorEvaluation = comparisonRoll == null ? await this.#rollPackDie(20) : null;
|
||
const actorRoll = Number(comparisonRoll ?? actorEvaluation?.kept ?? 0);
|
||
|
||
if (reference.kept !== actorRoll) {
|
||
return {
|
||
matched: false,
|
||
referenceRoll: reference.kept,
|
||
actorRoll,
|
||
uniqueRoll: null,
|
||
itemName: "",
|
||
itemData: null,
|
||
rolls: [reference.roll, ...(actorEvaluation ? [actorEvaluation.roll] : [])]
|
||
};
|
||
}
|
||
|
||
const uniqueRoll = await this.#rollPackDie(this.MISSION_PACK_UNIQUE_ITEMS.length);
|
||
const spec = this.MISSION_PACK_UNIQUE_ITEMS[Math.max(0, uniqueRoll.kept - 1)] ?? this.MISSION_PACK_UNIQUE_ITEMS[0];
|
||
const document = await this.#findItemByName(spec.name);
|
||
const itemData = document ? this.#toEmbeddedItemData(document) : this.#createInlineItemData(spec);
|
||
|
||
return {
|
||
matched: true,
|
||
referenceRoll: reference.kept,
|
||
actorRoll,
|
||
uniqueRoll: uniqueRoll.kept,
|
||
itemName: document?.name ?? spec.name,
|
||
itemData,
|
||
rolls: [reference.roll, ...(actorEvaluation ? [actorEvaluation.roll] : []), uniqueRoll.roll]
|
||
};
|
||
}
|
||
|
||
static async #resolveTableResultEntries(result, { multiple = false } = {}) {
|
||
if (!result) {
|
||
return {
|
||
display: game.i18n.localize("DNC.Macro.MissionPack.NoResult"),
|
||
entries: []
|
||
};
|
||
}
|
||
|
||
const source = result._source ?? {};
|
||
const resultName = String(result.name ?? source.name ?? "").trim();
|
||
const resultDescription = String(result.description ?? source.description ?? "").trim();
|
||
const resultText = String(source.text ?? "").trim();
|
||
const resultLabel = resultName || resultDescription || resultText;
|
||
|
||
const documentUuid = result.type === "document"
|
||
? result.documentUuid ?? source.documentUuid ?? null
|
||
: null;
|
||
|
||
if (documentUuid) {
|
||
const document = await fromUuid(documentUuid);
|
||
const label = document?.name ?? resultLabel;
|
||
return {
|
||
display: label,
|
||
entries: label ? [{ name: label, document }] : []
|
||
};
|
||
}
|
||
|
||
const sourceText = resultDescription || resultText || resultLabel;
|
||
const uuidTargets = this.#extractUuidTargets(sourceText);
|
||
if (uuidTargets.length) {
|
||
const entries = [];
|
||
for (const target of uuidTargets) {
|
||
const document = await fromUuid(target.uuid);
|
||
const name = document?.name ?? target.label;
|
||
if (!name) continue;
|
||
entries.push({ name, document });
|
||
}
|
||
|
||
return {
|
||
display: entries.map((entry) => entry.name).join(", "),
|
||
entries
|
||
};
|
||
}
|
||
|
||
const names = multiple
|
||
? this.#extractPlainTextEntries(sourceText || resultLabel)
|
||
: [resultLabel].filter(Boolean);
|
||
|
||
return {
|
||
display: names.join(", "),
|
||
entries: names.map((name) => ({ name, document: null }))
|
||
};
|
||
}
|
||
|
||
static #toEmbeddedItemData(item) {
|
||
const data = foundry.utils.deepClone(item.toObject());
|
||
delete data._id;
|
||
delete data.folder;
|
||
delete data.sort;
|
||
return data;
|
||
}
|
||
|
||
static async #materializeMissionPackEntries(entries) {
|
||
const embeddedItems = [];
|
||
const addedNames = [];
|
||
const missingNames = [];
|
||
|
||
for (const entry of entries) {
|
||
const item = entry.document ?? await this.#findItemByName(entry.name);
|
||
if (!item) {
|
||
missingNames.push(entry.name);
|
||
continue;
|
||
}
|
||
|
||
embeddedItems.push(this.#toEmbeddedItemData(item));
|
||
addedNames.push(item.name);
|
||
}
|
||
|
||
return {
|
||
embeddedItems,
|
||
addedNames,
|
||
missingNames
|
||
};
|
||
}
|
||
|
||
static async #openCampaignAllocationDialog(actor, { playerName = "", requesterName = "" } = {}) {
|
||
const content = await foundry.applications.handlebars.renderTemplate(
|
||
"systems/fvtt-donjon-et-cie/templates/dialogs/mission-pack-campaign-dialog.hbs",
|
||
{
|
||
actorName: actor.name,
|
||
playerName,
|
||
requesterName,
|
||
rank: Number(actor.system.anciennete?.rang ?? actor.system.sante?.dv ?? 0),
|
||
assignments: this.MISSION_PACK_TABLES.map((spec) => ({
|
||
key: spec.key,
|
||
label: this.#getMissionPackLabel(spec.key),
|
||
fieldName: `${spec.key}Die`,
|
||
selectedDie: this.#getDefaultCampaignAssignments()[spec.key]
|
||
})),
|
||
diceOptions: this.#getCampaignDiceOptions(),
|
||
relationOptions: this.#getStewardRelationOptions(),
|
||
selectedRelation: "neutral"
|
||
}
|
||
);
|
||
|
||
return foundry.applications.api.DialogV2.wait({
|
||
window: {
|
||
title: game.i18n.localize("DNC.Macro.MissionPack.CampaignDialogTitle"),
|
||
icon: "fa-solid fa-dice"
|
||
},
|
||
classes: ["dnc-roll-dialog", "dnc-mission-pack-dialog"],
|
||
content,
|
||
modal: true,
|
||
buttons: [
|
||
{
|
||
action: "confirm",
|
||
label: game.i18n.localize("DNC.Macro.MissionPack.CampaignDialogAction"),
|
||
icon: "fa-solid fa-check",
|
||
default: true,
|
||
callback: async (event, button) => {
|
||
const assignments = Object.fromEntries(this.MISSION_PACK_TABLES.map((spec) => [
|
||
spec.key,
|
||
Number(button.form.elements[`${spec.key}Die`]?.value ?? 0)
|
||
]));
|
||
const selectedDice = Object.values(assignments).filter((value) => value > 0);
|
||
|
||
if (selectedDice.length !== this.MISSION_PACK_TABLES.length) {
|
||
ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnDiceRequired"));
|
||
return null;
|
||
}
|
||
|
||
if (new Set(selectedDice).size !== selectedDice.length) {
|
||
ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnDiceUnique"));
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
assignments,
|
||
stewardRelation: button.form.elements.stewardRelation?.value ?? "neutral"
|
||
};
|
||
}
|
||
}
|
||
],
|
||
rejectClose: false
|
||
});
|
||
}
|
||
|
||
static async #handleSocketMessage(payload) {
|
||
if (payload?.scope !== this.MISSION_PACK_SOCKET_SCOPE) return;
|
||
|
||
if (payload.type === "campaign-response") {
|
||
const pending = this.#campaignRequests.get(payload.requestId);
|
||
if (!pending) return;
|
||
clearTimeout(pending.timeoutId);
|
||
this.#campaignRequests.delete(payload.requestId);
|
||
pending.resolve(payload.result ?? null);
|
||
return;
|
||
}
|
||
|
||
if (payload.type !== "campaign-request" || payload.targetUserId !== game.user.id) return;
|
||
|
||
const actor = await fromUuid(payload.actorUuid);
|
||
const allocation = actor
|
||
? await this.#openCampaignAllocationDialog(actor, {
|
||
playerName: game.user.name,
|
||
requesterName: payload.requesterName ?? ""
|
||
})
|
||
: null;
|
||
|
||
game.socket.emit(`system.${game.system.id}`, {
|
||
scope: this.MISSION_PACK_SOCKET_SCOPE,
|
||
type: "campaign-response",
|
||
requestId: payload.requestId,
|
||
result: allocation
|
||
? {
|
||
allocation,
|
||
responderUserId: game.user.id,
|
||
responderName: game.user.name
|
||
}
|
||
: {
|
||
allocation: null,
|
||
responderUserId: game.user.id,
|
||
responderName: game.user.name
|
||
}
|
||
});
|
||
}
|
||
|
||
static async #requestCampaignAllocation(actor, targetUser) {
|
||
if (!targetUser || targetUser.id === game.user.id) {
|
||
const allocation = await this.#openCampaignAllocationDialog(actor, {
|
||
playerName: game.user.name,
|
||
requesterName: game.user.name
|
||
});
|
||
return allocation ? { allocation, responderName: game.user.name } : null;
|
||
}
|
||
|
||
const requestId = foundry.utils.randomID();
|
||
const responsePromise = new Promise((resolve) => {
|
||
const timeoutId = globalThis.setTimeout(() => {
|
||
this.#campaignRequests.delete(requestId);
|
||
resolve(null);
|
||
}, this.MISSION_PACK_REQUEST_TIMEOUT);
|
||
this.#campaignRequests.set(requestId, { resolve, timeoutId });
|
||
});
|
||
|
||
game.socket.emit(`system.${game.system.id}`, {
|
||
scope: this.MISSION_PACK_SOCKET_SCOPE,
|
||
type: "campaign-request",
|
||
requestId,
|
||
targetUserId: targetUser.id,
|
||
actorUuid: actor.uuid,
|
||
requesterName: game.user.name
|
||
});
|
||
|
||
const response = await responsePromise;
|
||
if (!response?.allocation) {
|
||
ui.notifications.warn(game.i18n.format("DNC.Macro.MissionPack.CampaignRequestCanceled", {
|
||
actor: actor.name,
|
||
player: targetUser.name
|
||
}));
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
allocation: response.allocation,
|
||
responderName: response.responderName ?? targetUser.name
|
||
};
|
||
}
|
||
|
||
static async #finalizeMissionPack(actor, drawPlans, {
|
||
generationMode = "classic",
|
||
controllerName = "",
|
||
stewardRelation = "neutral",
|
||
uniqueComparisonRoll = null
|
||
} = {}) {
|
||
const draws = [];
|
||
const embeddedItems = [];
|
||
const rolls = [];
|
||
let missingCount = 0;
|
||
|
||
for (const plan of drawPlans) {
|
||
rolls.push(...(plan.rolls ?? []).filter((roll) => roll instanceof Roll));
|
||
|
||
if (plan.failed || !plan.resolved) {
|
||
draws.push({
|
||
label: this.#getMissionPackLabel(plan.spec.key),
|
||
display: game.i18n.format("DNC.Macro.MissionPack.TableMissing", { table: plan.spec.name }),
|
||
addedNames: [],
|
||
addedSummary: "",
|
||
missingNames: [],
|
||
missingSummary: "",
|
||
failed: true
|
||
});
|
||
continue;
|
||
}
|
||
|
||
const materialized = await this.#materializeMissionPackEntries(plan.resolved.entries);
|
||
embeddedItems.push(...materialized.embeddedItems);
|
||
missingCount += materialized.missingNames.length;
|
||
|
||
draws.push({
|
||
label: this.#getMissionPackLabel(plan.spec.key),
|
||
display: plan.resolved.display || game.i18n.localize("DNC.Macro.MissionPack.NoResult"),
|
||
addedNames: materialized.addedNames,
|
||
addedSummary: materialized.addedNames.join(", "),
|
||
missingNames: materialized.missingNames,
|
||
missingSummary: materialized.missingNames.join(", "),
|
||
failed: false,
|
||
...plan.detail
|
||
});
|
||
}
|
||
|
||
const uniqueEntry = await this.#resolveUniqueMissionPackEntry({ comparisonRoll: uniqueComparisonRoll });
|
||
rolls.push(...(uniqueEntry.rolls ?? []).filter((roll) => roll instanceof Roll));
|
||
if (uniqueEntry.itemData) {
|
||
embeddedItems.push(uniqueEntry.itemData);
|
||
}
|
||
|
||
const createdItems = embeddedItems.length
|
||
? await actor.createEmbeddedDocuments("Item", embeddedItems, { renderSheet: false })
|
||
: [];
|
||
|
||
const content = await foundry.applications.handlebars.renderTemplate(
|
||
"systems/fvtt-donjon-et-cie/templates/chat/mission-pack-card.hbs",
|
||
{
|
||
title: game.i18n.localize("DNC.Macro.MissionPack.Title"),
|
||
actorName: actor.name,
|
||
createdCount: createdItems.length,
|
||
missingCount,
|
||
draws,
|
||
uniqueEntry,
|
||
isCampaign: generationMode === "campaign",
|
||
generationModeLabel: this.#getMissionPackModeLabel(generationMode),
|
||
controllerName,
|
||
stewardRelationLabel: generationMode === "campaign" ? this.#getStewardRelationLabel(stewardRelation) : ""
|
||
}
|
||
);
|
||
|
||
await ChatMessage.create({
|
||
speaker: ChatMessage.getSpeaker({ actor }),
|
||
user: game.user.id,
|
||
content,
|
||
rolls
|
||
});
|
||
|
||
if (createdItems.length && !missingCount) {
|
||
ui.notifications.info(game.i18n.format("DNC.Macro.MissionPack.Success", {
|
||
actor: actor.name,
|
||
count: createdItems.length
|
||
}));
|
||
} else if (createdItems.length) {
|
||
ui.notifications.warn(game.i18n.format("DNC.Macro.MissionPack.Partial", {
|
||
actor: actor.name,
|
||
count: createdItems.length,
|
||
missing: missingCount
|
||
}));
|
||
} else {
|
||
ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnNothingAdded"));
|
||
}
|
||
|
||
return {
|
||
actor,
|
||
createdItems,
|
||
missingCount,
|
||
draws,
|
||
uniqueEntry,
|
||
generationMode,
|
||
controllerName,
|
||
stewardRelation
|
||
};
|
||
}
|
||
|
||
static async #grantClassicMissionPack(actor) {
|
||
const drawPlans = [];
|
||
|
||
for (const spec of this.MISSION_PACK_TABLES) {
|
||
const table = await this.#findRollTableByName(spec.name);
|
||
if (!table) {
|
||
drawPlans.push({ spec, failed: true, resolved: null, rolls: [] });
|
||
continue;
|
||
}
|
||
|
||
const draw = await table.draw({ displayChat: false });
|
||
const result = draw.results?.[0] ?? null;
|
||
drawPlans.push({
|
||
spec,
|
||
failed: false,
|
||
resolved: await this.#resolveTableResultEntries(result, { multiple: spec.multiple }),
|
||
detail: null,
|
||
rolls: draw.roll instanceof Roll ? [draw.roll] : []
|
||
});
|
||
}
|
||
|
||
return this.#finalizeMissionPack(actor, drawPlans, {
|
||
generationMode: "classic"
|
||
});
|
||
}
|
||
|
||
static async #grantCampaignMissionPack(actor) {
|
||
const targetUser = this.#getCampaignTargetUser(actor);
|
||
const allocationResult = await this.#requestCampaignAllocation(actor, targetUser);
|
||
if (!allocationResult?.allocation) return null;
|
||
|
||
const rankBonus = Number(actor.system.anciennete?.rang ?? actor.system.sante?.dv ?? 0);
|
||
const stewardRelation = allocationResult.allocation.stewardRelation ?? "neutral";
|
||
const mode = this.MISSION_PACK_STEWARD_MODES[stewardRelation] ?? "normal";
|
||
const drawPlans = [];
|
||
let uniqueComparisonRoll = null;
|
||
|
||
for (const spec of this.MISSION_PACK_TABLES) {
|
||
const table = await this.#findRollTableByName(spec.name);
|
||
if (!table) {
|
||
drawPlans.push({ spec, failed: true, resolved: null, rolls: [] });
|
||
continue;
|
||
}
|
||
|
||
const sides = Number(allocationResult.allocation.assignments?.[spec.key] ?? 0);
|
||
const rollData = await this.#rollPackDie(sides, { mode });
|
||
if (sides === 20) uniqueComparisonRoll = rollData.kept;
|
||
|
||
const total = rollData.kept + rankBonus;
|
||
const tableResult = this.#findTableResultByTotal(table, total);
|
||
|
||
drawPlans.push({
|
||
spec,
|
||
failed: false,
|
||
resolved: await this.#resolveTableResultEntries(tableResult.result, { multiple: spec.multiple }),
|
||
detail: {
|
||
dieLabel: rollData.dieLabel,
|
||
modeLabel: rollData.modeLabel,
|
||
rollValuesLabel: rollData.valuesLabel,
|
||
kept: rollData.kept,
|
||
rankBonus,
|
||
total,
|
||
resolvedTotal: tableResult.clampedTotal,
|
||
clamped: tableResult.clampedTotal !== total
|
||
},
|
||
rolls: [rollData.roll]
|
||
});
|
||
}
|
||
|
||
return this.#finalizeMissionPack(actor, drawPlans, {
|
||
generationMode: "campaign",
|
||
controllerName: allocationResult.responderName ?? targetUser?.name ?? game.user.name,
|
||
stewardRelation,
|
||
uniqueComparisonRoll
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Open the GM-only mission pack dialog.
|
||
* @returns {Promise<object|null>}
|
||
*/
|
||
static async openMissionPackDialog() {
|
||
if (!game.user.isGM) {
|
||
ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnGMOnly"));
|
||
return null;
|
||
}
|
||
|
||
const actorOptions = this.#getMissionPackActorOptions();
|
||
if (!actorOptions.length) {
|
||
ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnNoEmployees"));
|
||
return null;
|
||
}
|
||
|
||
const mode = this.#getMissionPackMode();
|
||
const selectedActorId = this.#getDefaultMissionPackActorId(actorOptions.map((option) => game.actors.get(option.value)).filter(Boolean));
|
||
const content = await foundry.applications.handlebars.renderTemplate(
|
||
"systems/fvtt-donjon-et-cie/templates/dialogs/mission-pack-dialog.hbs",
|
||
{
|
||
actorOptions,
|
||
selectedActorId,
|
||
modeLabel: this.#getMissionPackModeLabel(mode),
|
||
modeDescription: this.#getMissionPackModeDescription(mode),
|
||
isCampaign: mode === "campaign"
|
||
}
|
||
);
|
||
|
||
return foundry.applications.api.DialogV2.wait({
|
||
window: {
|
||
title: game.i18n.localize("DNC.Macro.MissionPack.DialogTitle"),
|
||
icon: "fa-solid fa-box-open"
|
||
},
|
||
classes: ["dnc-roll-dialog"],
|
||
content,
|
||
modal: false,
|
||
buttons: [
|
||
{
|
||
action: "grant",
|
||
label: game.i18n.localize("DNC.Macro.MissionPack.DialogAction"),
|
||
icon: "fa-solid fa-box-open",
|
||
default: true,
|
||
callback: async (event, button) => {
|
||
const actorId = button.form.elements.actorId?.value ?? "";
|
||
const actor = actorId ? game.actors.get(actorId) : null;
|
||
if (!actor) {
|
||
ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnNoActor"));
|
||
return null;
|
||
}
|
||
return this.grantMissionPack(actor);
|
||
}
|
||
}
|
||
],
|
||
rejectClose: false
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Draw the initial mission pack for the resolved actor and add the resulting items.
|
||
* @param {Actor|string|null} target Resolved actor, token, or UUID. Defaults to selected token or user character.
|
||
* @returns {Promise<object|null>}
|
||
*/
|
||
static async grantMissionPack(target = null) {
|
||
const actor = await this.#resolveMissionPackActor(target);
|
||
if (!actor) {
|
||
ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnNoActor"));
|
||
return null;
|
||
}
|
||
|
||
if (actor.type !== "employe") {
|
||
ui.notifications.warn(game.i18n.localize("DNC.Macro.MissionPack.WarnInvalidActor"));
|
||
return null;
|
||
}
|
||
|
||
return this.#getMissionPackMode() === "campaign"
|
||
? this.#grantCampaignMissionPack(actor)
|
||
: this.#grantClassicMissionPack(actor);
|
||
}
|
||
}
|