Files
vermine2047/module/system/dialogs/rollDialog.mjs
T
uberwald 9cf7d9ac68
Release Creation / build (release) Failing after 1m20s
feat: système d'expérience et transferts de dés d'expérience
- phase d'expérience (récompenses collectives et individuelles, objectifs, traumatisme)
- jets d'apprentissage (compétences, spécialités, caractéristiques, réserves, évolution)
- bonus de réserve acquis par apprentissage intégré au max dérivé
- transferts de dés individuels <-> groupe dans le dialogue de jet
- onglet expérience sur la fiche personnage
- guide utilisateur combat (docs/user/COMBAT.md)
2026-08-04 14:14:09 +02:00

476 lines
16 KiB
JavaScript

import { VermineUtils } from "../roll.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
export default class RollDialog extends HandlebarsApplicationMixin(foundry.applications.api.ApplicationV2) {
#actor;
get title() {
return game.i18n.localize("VERMINE.roll");
}
static DEFAULT_OPTIONS = {
classes: ["vermine-roll"],
tag: "form",
window: {
icon: "fas fa-dice-d10",
resizable: false
},
position: {
width: 520,
height: 600
},
actions: {
roll: RollDialog.#onRoll,
cancel: RollDialog.#onCancel,
giveToGroup: RollDialog.#onGiveToGroup
}
};
static PARTS = {
main: { template: "systems/vermine2047/templates/dialogs/roll-dialog.hbs" }
};
static async create(data = {}) {
if (data.actor instanceof Actor) {
return new RollDialog({ actor: data.actor, label: data.label, rolltype: data.rolltype, weapon: data.weapon, locks: data.locks, defense: data.defense });
}
const actorId = data.actorId ?? game.user.character?.id ?? canvas.tokens.controlled[0]?.actor?.id;
if (!actorId || typeof actorId !== "string") {
ui.notifications.warn(game.i18n.localize("VERMINE.error_no_actor_selected"));
return null;
}
const actor = await game.actors.get(actorId);
if (!actor) {
ui.notifications.warn(game.i18n.localize("VERMINE.error_no_actor_selected"));
return null;
}
return new RollDialog({ actor, label: data.label, rolltype: data.rolltype, weapon: data.weapon, locks: data.locks, defense: data.defense });
}
constructor(options = {}) {
super(options);
this.#actor = options.actor;
this.label = options.label ?? null;
this.rolltype = options.rolltype ?? null;
this.weapon = options.weapon ?? null;
this.locks = options.locks ?? null;
this.defense = options.defense ?? null;
}
async _prepareContext() {
const actor = this.#actor;
const isCharacter = actor.type === "character";
const group = isCharacter ? RollDialog.#findGroup(actor) : null;
const groupPool = group?.system?.experience?.dice || 0;
return {
actor,
system: actor.system,
config: CONFIG.VERMINE,
label: this.label,
rollType: this.rolltype,
labelKey: this.label,
speakerId: actor.id,
ability: null,
help: false,
specialty: false,
availableSpecialties: actor.items.filter(i => i.type === "specialty"),
availableItems: actor.items.filter(i => i.type === "item"),
locks: this.locks,
experience: {
hasExperience: isCharacter,
individual: isCharacter ? actor.system?.experience?.dice || 0 : 0,
groupPool,
pullOptions: [0, 1, 2].filter(v => v <= Math.min(2, groupPool)),
canPull: Boolean(group) && (game.user.isGM || group.isOwner),
canGive: isCharacter && (game.user.isGM || actor.isOwner)
}
};
}
async _onRender(context, options) {
this.element.dataset.actorId = this.#actor.id;
// Mode verrouillé (défense en échange de coups) : présélectionne et bloque
// caractéristique + difficulté, présélectionne la compétence.
if (this.locks) {
this.#applyLocks(this.locks);
}
for (const inp of this.element.querySelectorAll("[data-roll]")) {
inp.addEventListener("change", this.#onInputChange.bind(this));
}
const ability = this.element.querySelector("#ability");
if (ability) {
ability.addEventListener("change", this.#onChangeAbility.bind(this));
const selfControl = this.element.querySelector("#self_control");
if (selfControl) selfControl.max = ability.value;
}
const selfControl = this.element.querySelector("#self_control");
if (selfControl) {
selfControl.addEventListener("change", this.#onChangeSelfControl.bind(this));
}
this.element.querySelector("#difficulty")?.addEventListener("change", () => this.#updateUI());
this.element.querySelector("#handicap")?.addEventListener("change", () => this.#updateUI());
this.element.querySelector("#human-totem")?.addEventListener("change", () => this.#updateUI());
this.element.querySelector("#adapted-totem")?.addEventListener("change", () => this.#updateUI());
this.#displaySpecialties();
this.#refreshExperienceUI();
this.#updateUI();
if (ability?.value !== "0") {
this.element.querySelector("#self_control")?.dispatchEvent(new Event("change"));
}
}
// ── Getters ──────────────────────────────────────────────────────────
get #el() { return this.element; }
#getAbility() { return this.#el.querySelector("#ability"); }
#getSkill() { return this.#el.querySelector("#skill"); }
#getDifficulty() { return this.#el.querySelector("#difficulty"); }
#getHandicap() { return this.#el.querySelector("#handicap"); }
#getSelfCtrl() { return this.#el.querySelector("#self_control"); }
#getExperiencePull() { return parseInt(this.#el.querySelector("#experience-pull")?.value, 10) || 0; }
/**
* Trouve le premier Groupe dont le personnage est membre.
* @param {Actor} actor
* @returns {Actor|null}
*/
static #findGroup(actor) {
return game.actors.filter(a => a.type === "group" && (a.system?.members ?? []).includes(actor.id))[0] ?? null;
}
/** Décomposition du pool de dés pour affichage dans le message. */
getPoolBreakdown() {
const abil = this.#getAbility();
const ability = parseInt(abil?.options[abil?.selectedIndex]?.value, 10) || 0;
const skill = this.#getSkill();
const skillPool = parseInt(skill?.options[skill?.selectedIndex]?.dataset?.pool, 10) || 0;
const selfControl = parseInt(this.#getSelfCtrl()?.value, 10) || 0;
const specChecked = this.hasSpecialtySelected();
const helped = this.#el.querySelector("#helped")?.checked;
const toolsChecked = this.#el.querySelector("input[name='usingTools']:checked");
const tools = toolsChecked && toolsChecked.value !== "0";
const group = parseInt(this.#el.querySelector("#group")?.value, 10) || 0;
const experience = this.#getExperiencePull();
const specialty = specChecked ? 1 : 0;
const help = helped ? 1 : 0;
const tooling = tools ? 1 : 0;
const total = ability + skillPool + selfControl + specialty + help + tooling + group + experience;
return { ability, skill: skillPool, selfControl, specialty, help, tooling, group, experience, total };
}
getDicePool() {
return this.getPoolBreakdown().total;
}
getSpecialtyName() {
const checked = this.#el.querySelector("input[name='usingSpecialization']:checked");
return checked && checked.value !== "aucune" ? checked.value : null;
}
getDifficultySelect() {
const sel = this.#getDifficulty();
const idx = sel?.selectedIndex ?? 0;
return parseInt(sel?.options[idx]?.value, 10) || 7;
}
getReroll() {
const sel = this.#getSkill();
const idx = sel?.selectedIndex ?? 0;
return parseInt(sel?.options[idx]?.dataset?.reroll, 10) || 0;
}
getHandicapSelect() {
const sel = this.#getHandicap();
return Math.max(0, (parseInt(sel?.value, 10) || 1) - 1);
}
getSkillCategory() {
const sel = this.#getSkill();
const idx = sel?.selectedIndex ?? 0;
return sel?.options[idx]?.dataset?.category ?? null;
}
getSkillLevel() {
const sel = this.#getSkill();
const idx = sel?.selectedIndex ?? 0;
const val = sel?.options[idx]?.value;
return val ? parseInt(val, 10) : null;
}
hasSpecialtySelected() {
const checked = this.#el.querySelector("input[name='usingSpecialization']:checked");
return checked && checked.value !== "aucune";
}
getRollType() {
const sel = this.#getSkill();
return sel?.value ? "skill" : "ability";
}
getLabel() {
const type = this.getRollType();
if (type === "skill") {
const sel = this.#getSkill();
const idx = sel?.selectedIndex ?? 0;
return sel?.options[idx]?.dataset?.label ?? "";
}
const sel = this.#getAbility();
const idx = sel?.selectedIndex ?? 0;
return sel?.options[idx]?.dataset?.label ?? "";
}
getSelfControl() {
return parseInt(this.#getSelfCtrl()?.value, 10) || 0;
}
getMaxEffort() {
const sel = this.#getAbility();
return parseInt(sel?.value, 10) || 0;
}
getTotems() {
return {
human: this.#el.querySelector("#human-totem")?.checked ?? false,
adapted: this.#el.querySelector("#adapted-totem")?.checked ?? false
};
}
getKeepTotem() {
return this.#el.querySelector("#keep-totem-select")?.value ?? null;
}
// ── UI ───────────────────────────────────────────────────────────────
#applyLocks(locks) {
const ability = this.#getAbility();
if (ability && locks.ability) {
const opt = [...ability.options].find(o => o.dataset.label === locks.ability);
if (opt) {
opt.selected = true;
ability.disabled = true;
const scoreEl = this.#el.querySelector("#abilityScore");
if (scoreEl) scoreEl.value = opt.value;
const sc = this.#getSelfCtrl();
if (sc) sc.max = opt.value;
}
}
const skill = this.#getSkill();
if (skill && locks.skill) {
const opt = [...skill.options].find(o => o.dataset.label === locks.skill);
if (opt) opt.selected = true;
}
const difficulty = this.#getDifficulty();
if (difficulty && locks.difficulty !== undefined && locks.difficulty !== null) {
const locked = parseInt(locks.difficulty, 10);
let opt = [...difficulty.options].find(o => parseInt(o.value, 10) === locked);
if (!opt) {
// Difficulté hors des options (ex: 9 + 2) : ajoute une option dédiée.
opt = document.createElement("option");
opt.value = String(locked);
opt.textContent = `${game.i18n.localize("VERMINE.difficulty")} (${locked})`;
difficulty.appendChild(opt);
}
opt.selected = true;
difficulty.disabled = true;
}
}
#displaySpecialties() {
for (const el of this.#el.querySelectorAll("[data-spec-skill]")) {
el.style.display = "inline";
}
}
#calculateBonusCount() {
const b = this.getPoolBreakdown();
return b.specialty + b.help + b.tooling + b.group + b.selfControl + b.experience;
}
#refreshExperienceUI() {
const actor = this.#actor;
const group = RollDialog.#findGroup(actor);
const individual = actor.system?.experience?.dice || 0;
const groupPool = group?.system?.experience?.dice || 0;
const indEl = this.#el.querySelector("#exp-individual");
if (indEl) indEl.textContent = individual;
const grpEl = this.#el.querySelector("#exp-group");
if (grpEl) grpEl.textContent = groupPool;
const pull = this.#el.querySelector("#experience-pull");
if (pull) {
const max = Math.min(2, groupPool);
const current = Math.min(parseInt(pull.value, 10) || 0, max);
pull.innerHTML = "";
for (let i = 0; i <= max; i++) {
const opt = document.createElement("option");
opt.value = String(i);
opt.textContent = `${i}D`;
if (i === current) opt.selected = true;
pull.appendChild(opt);
}
}
}
#updateUI() {
const total = this.getDicePool();
const totalEl = this.#el.querySelector("#dice-pool-total");
if (totalEl) totalEl.textContent = `${total}D`;
const bonusEl = this.#el.querySelector("#total-bonus");
if (bonusEl) bonusEl.textContent = this.#calculateBonusCount();
const diffSel = this.#getDifficulty();
const diffEl = this.#el.querySelector("#current-difficulty");
if (diffEl && diffSel) {
const idx = diffSel.selectedIndex;
const val = diffSel.options[idx].value;
const lbl = diffSel.options[idx].text.split(" ")[0];
diffEl.textContent = `${lbl} (${val})`;
}
const handSel = this.#getHandicap();
const handEl = this.#el.querySelector("#current-handicap");
if (handEl && handSel) {
handEl.textContent = handSel.options[handSel.selectedIndex].text;
}
const abilSel = this.#getAbility();
const abilValEl = this.#el.querySelector("#abilityScoreValue");
if (abilSel && abilValEl) {
const idx = abilSel.selectedIndex;
abilValEl.textContent = idx > 0 ? abilSel.options[idx].value : "0";
}
const specChecked = this.#el.querySelector("input[name='usingSpecialization']:checked");
const specEl = this.#el.querySelector(".current-specialty");
if (specEl && specChecked) {
specEl.textContent = specChecked.value === "aucune"
? game.i18n.localize("VERMINE.none")
: specChecked.value;
}
}
// ── Event handlers ───────────────────────────────────────────────────
#onInputChange() {
this.#updateUI();
}
#onChangeAbility(ev) {
const sel = ev.currentTarget;
const score = sel.options[sel.selectedIndex]?.value ?? "0";
const scoreEl = this.#el.querySelector("#abilityScore");
if (scoreEl) scoreEl.value = score;
const sc = this.#getSelfCtrl();
if (sc) sc.max = score;
this.#updateUI();
}
#onChangeSelfControl(ev) {
const valEl = this.#el.querySelector("#self_control_value");
if (valEl) valEl.textContent = ev.currentTarget.value;
}
static async #onCancel(event, target) {
this.close();
}
static async #onGiveToGroup(event, target) {
const actor = this.#actor;
if (actor.type !== "character") return;
const individual = actor.system?.experience?.dice || 0;
if (individual < 1) {
ui.notifications.warn(game.i18n.localize("VERMINE.error_no_experience_dice"));
return;
}
if (!game.user.isGM && !actor.isOwner) {
ui.notifications.warn(game.i18n.localize("VERMINE.error_no_permission"));
return;
}
const group = RollDialog.#findGroup(actor);
if (!group) {
ui.notifications.warn(game.i18n.localize("VERMINE.error_no_group"));
return;
}
const groupPool = group.system?.experience?.dice || 0;
await actor.update({ "system.experience.dice": individual - 1 });
await group.update({ "system.experience.dice": groupPool + 1 });
this.#refreshExperienceUI();
}
static async #onRoll(event, target) {
const selfCtrl = this.getSelfControl();
if (selfCtrl > 0) {
const current = this.#actor?.system?.attributes?.self_control?.value ?? 0;
if (current < selfCtrl) {
ui.notifications.warn(game.i18n.localize("VERMINE.error_not_enough_self_control"));
return;
}
}
const abilityVal = this.#el.querySelector('[name="ability"]')?.value;
if (!abilityVal || abilityVal === "0") {
ui.notifications.warn(game.i18n.localize("VERMINE.error_select_ability"));
return;
}
if (selfCtrl > 0) {
const newVal = this.#actor.system.attributes.self_control.value - selfCtrl;
await this.#actor.update({ "system.attributes.self_control.value": newVal });
}
// Transfert d'Expérience : puiser dans la Réserve de Groupe (1-2D).
const pulled = this.#getExperiencePull();
if (pulled > 0) {
const group = RollDialog.#findGroup(this.#actor);
if (!group) {
ui.notifications.warn(game.i18n.localize("VERMINE.error_no_group"));
return;
}
if (!game.user.isGM && !group.isOwner) {
ui.notifications.warn(game.i18n.localize("VERMINE.error_no_permission"));
return;
}
const groupPool = group.system?.experience?.dice || 0;
if (groupPool < pulled) {
ui.notifications.warn(game.i18n.localize("VERMINE.error_no_group_dice"));
return;
}
await group.update({ "system.experience.dice": groupPool - pulled });
}
await VermineUtils.roll({
actor: this.#actor,
NoD: this.getDicePool(),
Reroll: this.getReroll(),
difficulty: this.getDifficultySelect(),
handicap: this.getHandicapSelect(),
rollLabel: this.getLabel(),
totems: this.getTotems(),
self_control: selfCtrl,
max_effort: this.getMaxEffort(),
keepTotem: this.getKeepTotem(),
skillCategory: this.getSkillCategory(),
skillLevel: this.getSkillLevel(),
hasSpecialty: this.hasSpecialtySelected(),
poolBreakdown: this.getPoolBreakdown(),
specialtyName: this.getSpecialtyName(),
weapon: this.weapon?.toObject() ?? null,
targets: [...game.user.targets].map(t => t.name),
messageFlags: this.defense ? { "vermine-defense": this.defense } : null
});
this.close();
}
}