335 lines
12 KiB
JavaScript
335 lines
12 KiB
JavaScript
/**
|
||
* 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,
|
||
});
|
||
}
|
||
}
|