Files
fvtt-donjon-et-cie/modules/donjon-et-cie-macros.mjs
T
uberwald 755c349078
Release Creation / build (release) Successful in 1m26s
DsN OK, objets uniques, règles de campagne
2026-05-03 21:51:35 +02:00

926 lines
30 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 20252026 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 &amp; 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);
}
}