diff --git a/lang/fr.json b/lang/fr.json
index 7933834..0fdeebf 100644
--- a/lang/fr.json
+++ b/lang/fr.json
@@ -103,6 +103,10 @@
"DNC.Warn.SpellInsufficientResources": "Le lanceur ne dispose pas d'assez de PV et de focus pour payer ce sort.",
"DNC.Warn.ChaosUnavailable": "Le Chaos n'est pas disponible pour ce sort.",
"DNC.Warn.TrainingExhausted": "Cet entrainement est epuise pour aujourd'hui. Reinitialisez son delta quotidien pour le lendemain.",
+ "DNC.Settings.MissionPackMode.Name": "Mode de paquetage de mission",
+ "DNC.Settings.MissionPackMode.Hint": "Choisit entre le tirage classique rapide et la regle optionnelle de campagne avec allocation des des par les joueurs.",
+ "DNC.Settings.MissionPackMode.Classic": "Classique",
+ "DNC.Settings.MissionPackMode.Campaign": "Campagne",
"DNC.Macro.MissionPack.Title": "Paquetage de debut de mission",
"DNC.Macro.MissionPack.ItemsAdded": "Objets ajoutes",
"DNC.Macro.MissionPack.ItemsMissing": "Objets manquants",
@@ -121,13 +125,46 @@
"DNC.Macro.MissionPack.Partial": "{actor} recoit {count} objet(s), mais {missing} entree(s) du paquetage sont introuvables.",
"DNC.Macro.MissionPack.DialogTitle": "Paquetage de mission",
"DNC.Macro.MissionPack.DialogIntro": "Selectionnez l'employe qui recevra le paquetage de debut de mission, puis lancez le tirage.",
+ "DNC.Macro.MissionPack.ActiveMode": "Mode :",
+ "DNC.Macro.MissionPack.ModeClassic": "Classique",
+ "DNC.Macro.MissionPack.ModeClassicHint": "Le systeme tire directement les 4 tables d equipement, comme dans les one-shots.",
+ "DNC.Macro.MissionPack.ModeCampaign": "Campagne",
+ "DNC.Macro.MissionPack.ModeCampaignHint": "Le joueur alloue 1d20, 1d12, 1d10 et 1d8 aux 4 categories, puis l anciennete est ajoutee a chaque resultat.",
"DNC.Macro.MissionPack.DialogActor": "Employe",
- "DNC.Macro.MissionPack.DialogAction": "Generer le paquetage",
- "DNC.Macro.MissionPack.SidebarButton": "Paquetage",
- "DNC.Macro.MissionPack.melee": "Arme de corps a corps",
- "DNC.Macro.MissionPack.ranged": "Arme a distance",
- "DNC.Macro.MissionPack.armor": "Armure",
- "DNC.Macro.MissionPack.misc": "Encas et equipement divers",
+ "DNC.Macro.MissionPack.DialogAction": "Generer le paquetage",
+ "DNC.Macro.MissionPack.SidebarButton": "Paquetage",
+ "DNC.Macro.MissionPack.CampaignDialogLead": "En mode campagne, le joueur proprietaire choisira l affectation de ses des dans un dialogue dedie.",
+ "DNC.Macro.MissionPack.CampaignDialogTitle": "Paquetage de campagne",
+ "DNC.Macro.MissionPack.CampaignDialogSubtitle": "Repartition logistique",
+ "DNC.Macro.MissionPack.CampaignDialogIntro": "{actor} prepare son paquetage de campagne. Repartissez les des entre les categories, puis indiquez la relation a l econome.",
+ "DNC.Macro.MissionPack.CampaignDialogAction": "Valider l allocation",
+ "DNC.Macro.MissionPack.CampaignDialogPlayer": "Joueur",
+ "DNC.Macro.MissionPack.CampaignDialogRequester": "Demande de",
+ "DNC.Macro.MissionPack.CampaignDialogRank": "Anciennete",
+ "DNC.Macro.MissionPack.CampaignDialogRelation": "Relation a l econome",
+ "DNC.Macro.MissionPack.CampaignDialogAssignHelp": "Attribuez un de different a chaque categorie de paquetage.",
+ "DNC.Macro.MissionPack.CampaignDialogHelp": "Le d20 alloue servira aussi pour verifier l equipement unique. L anciennete est ajoutee a chaque jet apres avantage ou desavantage.",
+ "DNC.Macro.MissionPack.CampaignController": "Choix joueur :",
+ "DNC.Macro.MissionPack.CampaignRelation": "Relation :",
+ "DNC.Macro.MissionPack.CampaignRequestCanceled": "Le paquetage de campagne pour {actor} n a pas ete valide par {player}.",
+ "DNC.Macro.MissionPack.WarnDiceRequired": "Attribuez un de a chacune des 4 categories du paquetage.",
+ "DNC.Macro.MissionPack.WarnDiceUnique": "Chaque de ne peut etre utilise qu une seule fois.",
+ "DNC.Macro.MissionPack.Relation.positive": "Positive",
+ "DNC.Macro.MissionPack.Relation.neutral": "Neutre",
+ "DNC.Macro.MissionPack.Relation.negative": "Negative",
+ "DNC.Macro.MissionPack.RollDetail": "{die} · {mode} · lancers {values} · garde {kept} · + anciennete {rank} = {total}",
+ "DNC.Macro.MissionPack.TotalClamped": "Total retenu sur la table : {clamped} (au lieu de {total}).",
+ "DNC.Macro.MissionPack.melee": "Arme de corps a corps",
+ "DNC.Macro.MissionPack.ranged": "Arme a distance",
+ "DNC.Macro.MissionPack.armor": "Armure",
+ "DNC.Macro.MissionPack.misc": "Encas et equipement divers",
+ "DNC.Macro.MissionPack.UniqueReference": "d20 de reference",
+ "DNC.Macro.MissionPack.UniqueActorRoll": "d20 du joueur",
+ "DNC.Macro.MissionPack.UniqueMatch": "Objet unique",
+ "DNC.Macro.MissionPack.UniqueMiss": "Pas d objet unique",
+ "DNC.Macro.MissionPack.UniqueGranted": "Objet unique ajoute :",
+ "DNC.Macro.MissionPack.UniqueTableRoll": "tirage d objet unique",
+ "DNC.Macro.MissionPack.UniqueRuleReminder": "Aucun objet unique supplementaire cette fois ci.",
"DNC.Welcome.Kicker": "Accueil",
"DNC.Welcome.Title": "Bienvenue dans Donjon & Cie",
"DNC.Welcome.Subtitle": "Systeme FoundryVTT · version {version}",
diff --git a/less/dialogs.less b/less/dialogs.less
index 02c9fc1..087bfcc 100644
--- a/less/dialogs.less
+++ b/less/dialogs.less
@@ -34,3 +34,149 @@
.dnc-roll-dialog .window-content {
background: linear-gradient(180deg, #f7efe0 0%, #e3d0b1 100%);
}
+
+.dnc-mission-pack-mode,
+.dnc-mission-pack-note {
+ font-size: 0.9rem;
+}
+
+.dnc-mission-pack-campaign {
+ gap: @spacing-lg;
+}
+
+.dnc-mission-pack-hero {
+ padding: @spacing-lg;
+ border: 1px solid fade(@color-border, 35%);
+ border-radius: @radius-md;
+ background:
+ linear-gradient(180deg, fade(#ffffff, 65%) 0%, fade(@color-panel, 68%) 100%),
+ linear-gradient(135deg, fade(@color-accent, 8%) 0%, fade(@color-accent, 0%) 100%);
+ box-shadow: 0 8px 18px fade(@color-shadow, 10%);
+}
+
+.dnc-mission-pack-kicker {
+ margin: 0 0 0.25rem;
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: fade(@color-accent, 82%);
+}
+
+.dnc-mission-pack-hero h2 {
+ margin: 0;
+ font-family: @font-display;
+ font-size: 1.4rem;
+ line-height: 1.1;
+ color: @color-accent;
+}
+
+.dnc-mission-pack-subtitle {
+ margin-top: 0.2rem;
+ font-size: 0.78rem;
+ font-weight: 700;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: fade(@color-border, 78%);
+}
+
+.dnc-mission-pack-intro {
+ margin-top: @spacing-sm;
+ color: @color-muted;
+}
+
+.dnc-mission-pack-meta-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: @spacing-md;
+}
+
+.dnc-mission-pack-meta-card {
+ display: grid;
+ gap: 0.2rem;
+ padding: @spacing-md;
+ border: 1px solid fade(@color-border, 30%);
+ border-radius: @radius-md;
+ background: fade(#ffffff, 42%);
+}
+
+.dnc-mission-pack-meta-card span {
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: @color-muted;
+}
+
+.dnc-mission-pack-meta-card strong {
+ font-size: 1rem;
+}
+
+.dnc-mission-pack-section {
+ display: grid;
+ gap: @spacing-md;
+}
+
+.dnc-mission-pack-select {
+ padding: @spacing-md;
+ border: 1px solid fade(@color-border, 32%);
+ border-radius: @radius-md;
+ background: fade(#ffffff, 36%);
+}
+
+.dnc-mission-pack-assignments {
+ display: grid;
+ gap: @spacing-md;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.dnc-mission-pack-assignment {
+ display: grid;
+ gap: @spacing-sm;
+ padding: @spacing-md;
+ border: 1px solid fade(@color-border, 35%);
+ border-radius: @radius-md;
+ background: fade(#ffffff, 38%);
+ box-shadow: inset 0 1px 0 fade(#ffffff, 55%);
+}
+
+.dnc-mission-pack-assignment span {
+ font-size: 0.82rem;
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.dnc-mission-pack-note {
+ color: @color-muted;
+}
+
+.dnc-mission-pack-note-foot {
+ padding: @spacing-md;
+ border-top: 1px solid fade(@color-border, 24%);
+}
+
+.dnc-mission-pack-dialog .window-header {
+ background:
+ linear-gradient(180deg, fade(#fdf6e7, 96%) 0%, fade(#ead4aa, 96%) 100%),
+ linear-gradient(90deg, fade(@color-accent, 12%) 0%, fade(@color-accent, 0%) 100%);
+ border-bottom: 1px solid fade(@color-border, 35%);
+}
+
+.dnc-mission-pack-dialog .window-title {
+ color: @color-accent;
+ font-family: @font-display;
+ letter-spacing: 0.03em;
+ text-shadow: none;
+}
+
+.dnc-mission-pack-dialog .window-header button {
+ color: @color-ink;
+}
+
+@media (max-width: 640px) {
+ .dnc-mission-pack-meta-grid,
+ .dnc-mission-pack-assignments {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/modules/donjon-et-cie-macros.mjs b/modules/donjon-et-cie-macros.mjs
index ee644c4..02f37d8 100644
--- a/modules/donjon-et-cie-macros.mjs
+++ b/modules/donjon-et-cie-macros.mjs
@@ -13,6 +13,18 @@
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 },
@@ -20,6 +32,132 @@ export class DonjonEtCieMacros {
{ 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: "
Elle n a rien de particulier mais confere un prestige important.
Avantage a tous les jets d interaction avec les employes de Donjon & Cie.
",
+ notes: ""
+ }
+ },
+ {
+ name: "Fil a plomb d Arnezon",
+ type: "equipement",
+ system: {
+ quantite: 1,
+ equipee: false,
+ emplacement: "",
+ description: "Il oscille de maniere clairement etrange en presence d un passage secret.
",
+ notes: ""
+ }
+ },
+ {
+ name: "Boussole de Drize Durban",
+ type: "equipement",
+ system: {
+ quantite: 1,
+ equipee: false,
+ emplacement: "",
+ description: "Trois reglages : pointe vers le client, le resident ou l employe le plus proche.
",
+ notes: ""
+ }
+ },
+ {
+ name: "Boule de cristal de la supervision",
+ type: "equipement",
+ system: {
+ quantite: 1,
+ equipee: false,
+ emplacement: "",
+ description: "Permet de voir toute personne ou lieu qu on connait deja.
Jet de SAG pour la controler. La plupart des huiles sentent quand on les observe.
",
+ notes: ""
+ }
+ },
+ {
+ name: "Dent du dragon Leogradonardicus III",
+ type: "equipement",
+ system: {
+ quantite: 1,
+ equipee: false,
+ emplacement: "",
+ description: "Un jet de CHA reussi permet de controler les reptiles inintelligents et de charmer les humanoides reptiliens.
",
+ notes: ""
+ }
+ },
+ {
+ name: "Doigt d Aarcarcerax",
+ type: "consommable",
+ system: {
+ quantite: 1,
+ delta: 4,
+ effet: "Tue la creature vers qui on pointe le doigt.",
+ description: "Tue la creature vers qui on pointe le doigt.
La liche sait instantanement qu on a retrouve son doigt.
",
+ 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: "Delta 6 charges.
Permet de se transformer en 1-3 rats, 4-5 chauve-souris, 6 forme gazeuse.
",
+ 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: "Vieux carnet de notes d Affalella, directrice de la Mercatique.
Fonctionne comme Delta 6 faveurs de la Mercatique utilisables uniquement dans les aires client.
",
+ 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: "Ancienne cle universelle de Paiji, directeur de la Maintenance.
Delta 8 usages. Ouvre tous les coffres, portes, armoires et autres serrures du Donjon.
",
+ notes: ""
+ }
+ },
+ {
+ name: "Baguette d urgence",
+ type: "equipement",
+ system: {
+ quantite: 1,
+ equipee: false,
+ emplacement: "",
+ description: "Teleporte l utilisateur au palier des huiles lorsque la baguette est brisee.
",
+ 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")
@@ -34,6 +172,50 @@ export class DonjonEtCieMacros {
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;
@@ -50,6 +232,16 @@ export class DonjonEtCieMacros {
}));
}
+ 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;
@@ -121,6 +313,127 @@ export class DonjonEtCieMacros {
.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 {
@@ -129,19 +442,27 @@ export class DonjonEtCieMacros {
};
}
- if (result.type === "document" && result.documentCollection && result.documentId) {
- const uuid = result.documentCollection.includes(".")
- ? `Compendium.${result.documentCollection}.Item.${result.documentId}`
- : `Item.${result.documentId}`;
- const document = await fromUuid(uuid);
- const label = document?.name ?? result.text ?? "";
+ 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 uuidTargets = this.#extractUuidTargets(result.text);
+ const sourceText = resultDescription || resultText || resultLabel;
+ const uuidTargets = this.#extractUuidTargets(sourceText);
if (uuidTargets.length) {
const entries = [];
for (const target of uuidTargets) {
@@ -158,8 +479,8 @@ export class DonjonEtCieMacros {
}
const names = multiple
- ? this.#extractPlainTextEntries(result.text)
- : [result.text].map((entry) => String(entry ?? "").trim()).filter(Boolean);
+ ? this.#extractPlainTextEntries(sourceText || resultLabel)
+ : [resultLabel].filter(Boolean);
return {
display: names.join(", "),
@@ -175,6 +496,353 @@ export class DonjonEtCieMacros {
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