/** * Chiaroscuro Dice Dialog * * d6 pool system: ring value × multiplier d6, sum vs difficulty. * Multiplier: ×1 base, ×2 if aspect or assistance, ×3 if both. * Parangon passives adjust individual die results before summing. */ export class ChiaroscuroDiceDialog extends FormApplication { /** * Current Actor * @type {ActorL5r5e} * @private */ _actor = null; /** * Payload Object */ object = { ring: { id: "void", value: 1 }, skill: { id: "", name: "", bonus: 0, rank: "0" }, difficulty: { id: "moyenne", value: 10 }, modifier: 0, useAspectPoint: false, aspectType: "solar", useAssistance: false, }; static get defaultOptions() { return foundry.utils.mergeObject(super.defaultOptions, { id: "l5r5e-chiaroscuro-dice-dialog", classes: ["l5r5e", "chiaroscuro-dice-dialog"], template: CONFIG.l5r5e.paths.templates + "dice/chiaroscuro-dice-dialog.html", title: game.i18n.localize("chiaroscuro.dice.title"), width: 440, height: "auto", }); } get id() { return `l5r5e-chiaroscuro-dice-dialog-${this._actor?.id ?? "no-actor"}`; } get title() { return game.i18n.localize("chiaroscuro.dice.title") + (this._actor ? " — " + this._actor.name : ""); } /** * Total dice to roll (ring value × multiplier) * @return {number} */ get totalDice() { const base = this.object.ring.value; const both = this.object.useAspectPoint && this.object.useAssistance; const either = this.object.useAspectPoint || this.object.useAssistance; return base * (both ? 3 : either ? 2 : 1); } /** * @param options actor, actorId, ringId, skillId */ constructor(options = {}) { super({}, options); // Resolve actor [ options?.actor, game.actors.get(options?.actorId), canvas.tokens.controlled[0]?.actor, game.user.character, ].forEach((actor) => { if (!this._actor && actor instanceof Actor && actor.isOwner) { this._actor = actor; } }); // Default ring: options > actor default_ring > void const ringId = options.ringId ?? this._actor?.system?.default_ring ?? "void"; this.ringId = ringId; // Skill if (options.skillId) { this.skillId = options.skillId; } } /** * Set ring (id + value from actor) * @param {string} ringId */ set ringId(ringId) { this.object.ring.id = CONFIG.l5r5e.stances.includes(ringId) ? ringId : "void"; this.object.ring.value = this._actor?.system?.rings?.[this.object.ring.id] || 1; // Auto-derive aspect type from ring (fire/earth → solar, air/water → lunar; void = manual) if (this.object.ring.id !== "void") { this.object.aspectType = ["fire", "earth"].includes(this.object.ring.id) ? "solar" : "lunar"; } } /** * Set skill (id, name, rank, bonus) * @param {string} skillId */ set skillId(skillId) { if (!skillId) return; const catId = CONFIG.l5r5e.skills.get(skillId.toLowerCase().trim()); const rank = this._actor?.system?.skills?.[catId]?.[skillId] ?? "0"; this.object.skill = { ...this.object.skill, id: skillId, name: catId ? game.i18n.localize(`l5r5e.skills.${catId}.${skillId}`) : skillId, rank, bonus: CONFIG.l5r5e.skillRanks?.[rank]?.bonus ?? 0, }; } async getData(options = null) { const difficultiesList = Object.entries(CONFIG.l5r5e.difficulties).map(([id, value]) => ({ id, label: game.i18n.localize(`chiaroscuro.difficulties.${id}`), value, })); const aspectsList = [ { id: "solar", label: game.i18n.localize("chiaroscuro.aspects.solar") }, { id: "lunar", label: game.i18n.localize("chiaroscuro.aspects.lunar") }, ]; return { ...(await super.getData(options)), actor: this._actor, data: this.object, totalDice: this.totalDice, ringsList: game.l5r5e.HelpersL5r5e.getRingsList(this._actor), difficultiesList, aspectsList, isVoidRing: this.object.ring.id === "void", quickInfo: this._actor?.system?.quick_info ?? "", }; } activateListeners(html) { super.activateListeners(html); // Ring selector html.find(".ring-selection-chi").on("click", async (event) => { event.preventDefault(); event.stopPropagation(); this.ringId = event.currentTarget.dataset.ringid; this.render(false); }); // Difficulty select html.find("select[name='difficulty.id']").on("change", (event) => { this.object.difficulty.id = event.target.value; this.object.difficulty.value = CONFIG.l5r5e.difficulties[this.object.difficulty.id]; this.render(false); }); // Flat modifier html.find("input[name='modifier']").on("change", (event) => { this.object.modifier = parseInt(event.target.value) || 0; }); // Aspect point checkbox html.find("#use_aspect_point").on("change", (event) => { this.object.useAspectPoint = event.target.checked; this.render(false); }); // Aspect type select (solar / lunar) html.find("select[name='aspectType']").on("change", (event) => { this.object.aspectType = event.target.value; }); // Assistance checkbox html.find("#use_assistance").on("change", (event) => { this.object.useAssistance = event.target.checked; this.render(false); }); // Roll button — explicit submit trigger html.find("button[name='roll']").on("click", (event) => { event.preventDefault(); this._onSubmit(event); }); } async _updateObject(event, formData) { const nbDice = this.totalDice; const skillRank = this.object.skill.rank; const skillBonus = this.object.skill.bonus; const flatModifier = this.object.modifier; const difficultyObj = this.object.difficulty; const difficultyValue = difficultyObj.value; // Roll the dice using FoundryVTT Roll API const roll = await new Roll(`${nbDice}d6`).evaluate(); const rawResults = roll.dice[0].results.map((r) => r.result); // Apply parangon passive adjustments const adjustedResults = rawResults.map((r) => this._applyParangon(r, skillRank)); const diceAdjustedFlags = rawResults.map((r, i) => adjustedResults[i] !== r); const wasAdjusted = diceAdjustedFlags.some(Boolean); // Compute total const rawSum = adjustedResults.reduce((a, b) => a + b, 0); const total = rawSum + skillBonus + flatModifier; const success = total >= difficultyValue; const bonus = success ? total - difficultyValue : 0; // Update aspect gauge after roll if (this._actor && this.object.useAspectPoint) { await this._updateAspectGauge(); } // Post chat message await this._sendChatMessage({ nbDice, rawResults, adjustedResults, diceAdjustedFlags, wasAdjusted, rawSum, total, skillBonus, flatModifier, difficulty: difficultyObj, success, bonus, }); return this.close(); } /** * Apply parangon rank passive: replace low die results with higher value. * parangon1: 1 → 2 * parangon2: 1–2 → 3 * parangon3: 1–3 → 4 * @param {number} result * @param {string} rank * @return {number} */ _applyParangon(result, rank) { if (rank === "parangon3" && result <= 3) return 4; if (rank === "parangon2" && result <= 2) return 3; if (rank === "parangon1" && result <= 1) return 2; return result; } /** * Update the aspect gauge on the actor after an aspect point roll. * Gauge positive = solar side, negative = lunar side. * ±5 → apply Déséquilibre. ±10 → full reset. */ async _updateAspectGauge() { // Support both single-nested (system.aspects) and double-nested (system.aspects.aspects) const aspectsPath = this._actor.system.aspects?.aspects !== undefined ? "system.aspects.aspects" : "system.aspects"; const aspects = foundry.utils.getProperty(this._actor, aspectsPath) ?? {}; const gaugeDirection = this.object.aspectType === "solar" ? 1 : -1; const newGauge = (aspects.gauge ?? 0) + gaugeDirection; if (Math.abs(newGauge) >= 10) { // Full reset await this._actor.update({ [`${aspectsPath}.gauge`]: 0, [`${aspectsPath}.solar`]: 0, [`${aspectsPath}.lunar`]: 0, }); // Remove all desequilibre conditions const toRemove = this._actor.items .filter((i) => i.type === "etat" && ["desequilibre_solaire", "desequilibre_lunaire"].includes(i.system?.condition_type)) .map((i) => i.id); if (toRemove.length) { await this._actor.deleteEmbeddedDocuments("Item", toRemove); } } else { await this._actor.update({ [`${aspectsPath}.gauge`]: newGauge }); if (Math.abs(newGauge) >= 5) { // Apply opposing desequilibre const condType = this.object.aspectType === "solar" ? "desequilibre_lunaire" : "desequilibre_solaire"; const existing = this._actor.items.find( (i) => i.type === "etat" && i.system?.condition_type === condType ); if (!existing) { await this._actor.createEmbeddedDocuments("Item", [ { type: "etat", name: game.i18n.localize(`chiaroscuro.aspects.${condType}`), system: { condition_type: condType }, }, ]); } } } } /** * Create and send the chat message. */ async _sendChatMessage(rollData) { const content = await foundry.applications.handlebars.renderTemplate( CONFIG.l5r5e.paths.templates + "dice/chiaroscuro-chat-roll.html", { actor: this._actor, profileImg: this._actor?.img ?? "icons/svg/mystery-man.svg", ring: this.object.ring, skill: this.object.skill, difficulty: this.object.difficulty, useAspectPoint: this.object.useAspectPoint, aspectType: this.object.aspectType, useAssistance: this.object.useAssistance, modifier: this.object.modifier, quickInfo: this._actor?.system?.quick_info ?? "", ...rollData, } ); return ChatMessage.implementation.create({ user: game.user.id, speaker: { actor: this._actor?.id ?? null, alias: this._actor?.name ?? null, }, content, sound: CONFIG.sounds.dice, }); } }