import { VermineUtils } from "../roll.mjs"; import { VermineExperience } from "../experience.mjs"; const { HandlebarsApplicationMixin } = foundry.applications.api; /** * Dialogue d'apprentissage (phase d'Expérience). * Permet de dépenser des Dés d'Expérience pour tenter de développer une * Compétence, une Spécialité, une Caractéristique, une Réserve ou un * Dé d'Évolution. La progression est appliquée automatiquement en cas de * réussite. */ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.applications.api.ApplicationV2) { #actor; static DEFAULT_OPTIONS = { classes: ["vermine-roll"], tag: "form", window: { icon: "fas fa-graduation-cap", resizable: false }, position: { width: 560, height: 640 }, actions: { roll: LearningDialog.#onRoll, cancel: LearningDialog.#onCancel } }; static PARTS = { main: { template: "systems/vermine2047/templates/dialogs/learning-dialog.hbs", scrollable: [""] } }; static async create({ actorId }) { const actor = await game.actors.get(actorId); if (!actor) { ui.notifications.warn(game.i18n.localize("VERMINE.error_no_actor_selected")); return null; } return new LearningDialog({ actor }); } constructor(options = {}) { super(options); this.#actor = options.actor; } get title() { return game.i18n.localize("VERMINE.open_learning"); } async _prepareContext() { const actor = this.#actor; const skills = Object.entries(CONFIG.VERMINE.skills).map(([key, cfg]) => ({ key, name: game.i18n.localize("SKILLS." + key + ".name"), category: cfg.category, value: actor.system?.skills?.[key]?.value || 0, target: Math.min(5, (actor.system?.skills?.[key]?.value || 0) + 1), rarity: actor.system?.skills?.[key]?.rarity || 0, isDomain: VermineExperience.isPreferredDomain(actor, key) })); const totem = actor.system?.identity?.totem || ""; const gameMode = this.#getGameMode(); const groupLevel = this.#getGroupLevel(); const totemAbilities = (actor.itemTypes?.ability || []) .filter(i => i.system?.type === "character" && !i.system?.learned && i.system?.totem === totem && (i.system?.level?.value || 0) <= gameMode && groupLevel >= (i.system?.level?.value || 0) ) .map(a => ({ id: a.id, name: a.name, level: a.system.level.value, threshold: a.system.learn.threshold, hindrance: a.system.learn.hindrance })); const backgrounds = (actor.itemTypes?.background || []) .filter(i => !i.system?.learned) .map(b => ({ id: b.id, name: b.name, asterisk: b.system?.asterisk || false })); const human = actor.system?.adaptation?.totems?.human?.value || 0; const adapted = actor.system?.adaptation?.totems?.adapted?.value || 0; return { actor, system: actor.system, config: CONFIG.VERMINE, dice: actor.system?.experience?.dice || 0, ageType: actor.system?.identity?.ageType || 2, abilities: Object.entries(CONFIG.VERMINE.abilities).map(([key, label]) => ({ key, label: game.i18n.localize(label), value: actor.system?.abilities?.[key]?.value || 0 })), reserves: [ { key: "self_control", label: game.i18n.localize("VERMINE.self_control"), value: actor.system?.attributes?.self_control?.value || 0, max: actor.system?.attributes?.self_control?.max || 0 }, { key: "effort", label: game.i18n.localize("VERMINE.effort"), value: actor.system?.attributes?.effort?.value || 0, max: actor.system?.attributes?.effort?.max || 0 } ], specialties: actor.itemTypes?.specialty || [], skills, specialtyCount: (actor.itemTypes?.specialty || []).length, totem, gameMode, groupLevel, totemAbilities, backgrounds, reputation: actor.system?.attributes?.reputation?.value ?? 0, human, adapted, canUseAdaptedDie: VermineExperience.canUseAdaptedDie(human, adapted) }; } #getGameMode() { return parseInt(game.settings.get("vermine2047", "game-mode") || "1", 10) || 1; } #getGroupLevel() { const groups = game.actors.filter(a => a.type === "group" && (a.system?.members || []).includes(this.#actor.id)); return Math.max(0, ...groups.map(g => g.system?.level?.value || 0)); } #canUseAdaptedDie() { const a = this.#actor.system?.adaptation?.totems; return VermineExperience.canUseAdaptedDie(a?.human?.value || 0, a?.adapted?.value || 0); } #totemCapacityCounts() { // Ne comptabilise que les Capacités du Totem courant du personnage // (les Capacités liées à un autre Totem ne bloquent pas les limites). const totem = this.#actor.system?.identity?.totem; const items = (this.#actor.itemTypes?.ability || []).filter(i => i.system?.type === "character" && i.system?.learned && i.system?.totem === totem); const byLevel = { 1: 0, 2: 0, 3: 0 }; for (const i of items) { const level = i.system?.level?.value || 0; byLevel[level] = (byLevel[level] || 0) + 1; } return { total: items.length, byLevel }; } async _onRender() { this.element.dataset.actorId = this.#actor.id; for (const inp of this.element.querySelectorAll("[data-roll]")) { inp.addEventListener("change", () => this.#updateUI()); } this.element.querySelector("#learning-dice")?.addEventListener("input", () => this.#updateUI()); this.#updateUI(); } get #el() { return this.element; } #getType() { return this.#el.querySelector('input[name="learning-type"]:checked')?.value ?? "skill"; } #getTargetKey() { const type = this.#getType(); if (type === "characteristic") return this.#el.querySelector("#learning-ability")?.value ?? ""; if (type === "reserve") return this.#el.querySelector("#learning-reserve")?.value ?? ""; if (type === "totem") return this.#el.querySelector("#learning-totem")?.value ?? ""; if (type === "background") return this.#el.querySelector("#learning-background")?.value ?? ""; return this.#el.querySelector("#learning-target")?.value ?? ""; } #getReputationDirection() { return this.#el.querySelector("#learning-reputation-direction")?.value ?? "reduce"; } #isAdaptedDieChecked() { return Boolean(this.#el.querySelector("#learning-adapted-die")?.checked); } #getDiceSpent() { return Math.max(1, parseInt(this.#el.querySelector("#learning-dice")?.value, 10) || 1); } #getSpecialtyName() { return this.#el.querySelector("#specialty-name")?.value?.trim() || game.i18n.localize("ITEMS.new_specialty"); } #compute() { const actor = this.#actor; const type = this.#getType(); const key = this.#getTargetKey(); let label = ""; let targetLevel = null; if (type === "skill") { const current = key ? actor.system?.skills?.[key]?.value || 0 : 0; targetLevel = Math.min(5, current + 1); const rarity = key ? actor.system?.skills?.[key]?.rarity || 0 : 0; label = game.i18n.localize(VermineExperience.skillLevelLabelKey(targetLevel)); const diff = VermineExperience.skillLearningDifficulty(targetLevel); const domain = key ? VermineExperience.isPreferredDomain(actor, key) : false; return { type, key, targetLevel, difficulty: domain ? Math.max(3, diff - 2) : diff, handicap: VermineExperience.skillLearningHandicap(targetLevel, rarity), poolModifier: 0, label, targetLabel: key ? game.i18n.localize("SKILLS." + key + ".name") : "" }; } if (type === "specialty") { const current = key ? actor.system?.skills?.[key]?.value || 0 : 0; const domain = key ? VermineExperience.isPreferredDomain(actor, key) : false; label = game.i18n.localize("VERMINE.specialty"); return { type, key, targetLevel: current, difficulty: VermineExperience.specialtyLearningDifficulty(current, domain), handicap: VermineExperience.specialtyLearningHandicap(actor.itemTypes?.specialty?.length || 0), poolModifier: 0, label, targetLabel: key ? game.i18n.localize("SKILLS." + key + ".name") : "" }; } if (type === "characteristic") { const current = key ? actor.system?.abilities?.[key]?.value || 0 : 0; label = game.i18n.localize("VERMINE.characteristic"); return { type, key, targetLevel: current, difficulty: VermineExperience.characteristicLearningDifficulty(), handicap: current, poolModifier: 0, label, targetLabel: key ? game.i18n.localize(CONFIG.VERMINE.abilities[key]) : "" }; } if (type === "reserve") { const current = key ? actor.system?.attributes?.[key]?.value || 0 : 0; label = game.i18n.localize("VERMINE.reserve"); return { type, key, targetLevel: current, difficulty: VermineExperience.reserveLearningDifficulty(current), handicap: VermineExperience.ageHandicap(actor.system?.identity?.ageType || 2), poolModifier: 0, adaptedDie: false, label, targetLabel: key ? game.i18n.localize(key === "self_control" ? "VERMINE.self_control" : "VERMINE.effort") : "" }; } if (type === "totem") { const item = actor.itemTypes?.ability?.find(i => i.id === key); const data = item?.system; label = game.i18n.localize("VERMINE.learn_totem_ability"); return { type, key, targetLevel: data?.level?.value || 0, difficulty: VermineExperience.totemAbilityLearningDifficulty(data), handicap: VermineExperience.totemAbilityLearningHandicap(data), poolModifier: 0, adaptedDie: false, label, targetLabel: item?.name || "" }; } if (type === "background") { const item = actor.itemTypes?.background?.find(i => i.id === key); const data = item?.system; label = game.i18n.localize("VERMINE.learn_background"); return { type, key, targetLevel: 0, difficulty: VermineExperience.backgroundLearningDifficulty(), handicap: VermineExperience.backgroundLearningHandicap(data?.asterisk), poolModifier: 0, adaptedDie: false, label, targetLabel: item?.name || "" }; } if (type === "reputation") { const current = actor.system?.attributes?.reputation?.value ?? 0; const direction = this.#getReputationDirection(); if (direction === "increase") { return { type, key: "increase", targetLevel: current, difficulty: VermineExperience.reputationIncreaseDifficulty(current), handicap: 0, poolModifier: 0, adaptedDie: false, label: game.i18n.localize("VERMINE.learn_reputation"), targetLabel: game.i18n.localize("VERMINE.reputation_increase") }; } return { type, key: "reduce", targetLevel: Math.max(0, current - 1), difficulty: VermineExperience.reputationReduceDifficulty(current), handicap: 0, poolModifier: 0, adaptedDie: false, label: game.i18n.localize("VERMINE.learn_reputation"), targetLabel: game.i18n.localize("VERMINE.reputation_reduce") }; } // evolution const human = actor.system?.adaptation?.totems?.human?.value || 0; const adapted = actor.system?.adaptation?.totems?.adapted?.value || 0; label = game.i18n.localize("VERMINE.evolution_dice"); return { type, key, targetLevel: actor.system?.experience?.evolution || 0, difficulty: VermineExperience.evolutionDifficulty(actor.system?.identity?.ageType || 2), handicap: actor.system?.experience?.evolution || 0, poolModifier: VermineExperience.evolutionTotemModifier(human, adapted), adaptedDie: this.#canUseAdaptedDie() && this.#isAdaptedDieChecked(), label, targetLabel: "" }; } #updateUI() { const type = this.#getType(); const show = (id, visible) => { const el = this.#el.querySelector(id); if (el) el.style.display = visible ? "" : "none"; }; show("#learning-skill-section", type === "skill" || type === "specialty"); show("#learning-specialty-section", type === "specialty"); show("#learning-ability-section", type === "characteristic"); show("#learning-reserve-section", type === "reserve"); show("#learning-evolution-section", type === "evolution"); show("#learning-totem-section", type === "totem"); show("#learning-background-section", type === "background"); show("#learning-reputation-section", type === "reputation"); show("#learning-adapted-die-row", type === "evolution" && this.#canUseAdaptedDie()); // Pour une Spécialité, seule une compétence au moins Confirmée (≥2) est valable. const skillSelect = this.#el.querySelector("#learning-target"); if (skillSelect) { for (const opt of skillSelect.options) { const value = parseInt(opt.dataset.value, 10) || 0; opt.hidden = (type === "specialty") ? value < 2 : false; } } const c = this.#compute(); const set = (sel, txt) => { const el = this.#el.querySelector(sel); if (el) el.textContent = txt; }; set("#diff-value", c.difficulty); set("#handicap-value", c.handicap); set("#required-value", 1 + c.handicap); const pool = Math.max(0, this.#getDiceSpent() + c.poolModifier); let poolText = `${pool}D${c.poolModifier ? ` (${c.poolModifier >= 0 ? "+" : ""}${c.poolModifier}D)` : ""}`; if (c.adaptedDie) poolText += " — " + game.i18n.localize("VERMINE.adapted_die_x2"); set("#pool-value", poolText); if (type === "reputation") { const current = this.#actor.system?.attributes?.reputation?.value ?? 0; const direction = this.#getReputationDirection(); set("#reputation-target", direction === "increase" ? `${current} → ${Math.min(10, current + 1)}+` : `${current} → ${Math.max(0, current - 1)}`); } const poolInput = this.#el.querySelector("#learning-dice"); if (poolInput) poolInput.max = this.#actor.system?.experience?.dice || 0; } static async #onCancel() { this.close(); } static async #onRoll() { const actor = this.#actor; const ex = actor.system?.experience; const dice = ex?.dice || 0; const spent = Math.min(this.#getDiceSpent(), dice); if (spent < 1) { ui.notifications.warn(game.i18n.localize("VERMINE.error_no_experience_dice")); return; } const c = this.#compute(); // Limite : une seule compétence, spécialité, réserve, caractéristique, // un seul Dé d'Évolution, Capacité de Totem, Historique ou jet de // Réputation par phase. if (ex?.learned?.phase === ex?.phase) { const flagMap = { skill: "skill", specialty: "specialty", reserve: "reserve", characteristic: "characteristic", evolution: "evolution", totem: "ability", background: "background", reputation: "reputation" }; if (ex.learned[flagMap[c.type]]) { ui.notifications.warn(game.i18n.localize("VERMINE.error_learning_limit")); return; } } if (c.type === "skill" && c.targetLevel !== null && (actor.system?.skills?.[c.key]?.value || 0) >= 5) { ui.notifications.warn(game.i18n.localize("VERMINE.error_skill_max")); return; } if (c.type === "characteristic" && c.targetLevel !== null && (actor.system?.abilities?.[c.key]?.value || 0) >= 3) { ui.notifications.warn(game.i18n.localize("VERMINE.error_characteristic_max")); return; } if (c.type === "specialty" && c.targetLevel !== null && c.targetLevel < 2) { ui.notifications.warn(game.i18n.localize("VERMINE.error_specialty_requirement")); return; } if (c.type === "reserve" && c.targetLevel !== null && (actor.system?.attributes?.[c.key]?.max || 0) >= 10) { ui.notifications.warn(game.i18n.localize("VERMINE.error_reserve_max")); return; } if (c.type === "totem") { const item = actor.itemTypes?.ability?.find(i => i.id === c.key); if (!item || item.system?.learned) { ui.notifications.warn(game.i18n.localize("VERMINE.no_totem_ability")); return; } const level = item.system?.level?.value || 0; if (level > this.#getGameMode()) { ui.notifications.warn(game.i18n.localize("VERMINE.error_totem_mode")); return; } if (item.system?.totem !== actor.system?.identity?.totem) { ui.notifications.warn(game.i18n.localize("VERMINE.error_totem_totem")); return; } if (this.#getGroupLevel() < level) { ui.notifications.warn(game.i18n.localize("VERMINE.error_totem_group_level")); return; } const counts = this.#totemCapacityCounts(); if (level === 1 && (counts.byLevel[1] || 0) >= 3) { ui.notifications.warn(game.i18n.localize("VERMINE.error_totem_limit")); return; } if (level >= 2 && (counts.byLevel[level] || 0) >= 1) { ui.notifications.warn(game.i18n.localize("VERMINE.error_totem_limit")); return; } if (counts.total >= 5) { ui.notifications.warn(game.i18n.localize("VERMINE.error_totem_limit")); return; } } if (c.type === "background") { const item = actor.itemTypes?.background?.find(i => i.id === c.key); if (!item || item.system?.learned) { ui.notifications.warn(game.i18n.localize("VERMINE.no_background")); return; } } if (c.type === "reputation") { const current = actor.system?.attributes?.reputation?.value ?? 0; if (c.key === "reduce" && current <= 0) { ui.notifications.warn(game.i18n.localize("VERMINE.error_reputation_min")); return; } if (c.key === "increase" && current >= 10) { ui.notifications.warn(game.i18n.localize("VERMINE.error_reputation_max")); return; } } if (c.type === "evolution" && c.adaptedDie && !this.#canUseAdaptedDie()) { ui.notifications.warn(game.i18n.localize("VERMINE.error_evolution_no_adapted")); return; } const pool = Math.max(0, spent + c.poolModifier); const rollLabel = `${game.i18n.localize("VERMINE.open_learning")} — ${c.label}${c.targetLabel ? " (" + c.targetLabel + ")" : ""}`; const roll = await VermineUtils.roll({ actor, NoD: pool, Reroll: 0, difficulty: c.difficulty, handicap: c.handicap, totems: c.adaptedDie ? { adapted: true } : { human: false, adapted: false }, rollLabel, poolBreakdown: { ability: 0, skill: 0, selfControl: 0, specialty: 0, help: 0, tooling: 0, group: 0, total: pool }, messageFlags: { "vermine-learning": { type: c.type, key: c.key, spent, success: false } } }); const required = 1 + c.handicap; const specialtyName = this.#getSpecialtyName(); if ((roll._total ?? 0) >= required) { await this.#applySuccess(c, specialtyName, roll._total ?? 0); } // Les Dés d'Expérience sont perdus que le jet soit réussi ou raté. await actor.update({ "system.experience.dice": Math.max(0, (actor.system.experience.dice || 0) - spent) }); this.close(); } async #applySuccess(c, specialtyName, successes = 0) { const actor = this.#actor; const updates = {}; if (c.type === "skill") { const current = actor.system.skills[c.key].value || 0; updates["system.skills." + c.key + ".value"] = Math.min(5, current + 1); updates["system.experience.learned.skill"] = true; } else if (c.type === "specialty") { await actor.createEmbeddedDocuments("Item", [{ name: specialtyName, type: "specialty", system: { skill: c.key } }]); updates["system.experience.learned.specialty"] = true; } else if (c.type === "characteristic") { const current = actor.system.abilities[c.key].value || 0; updates["system.abilities." + c.key + ".value"] = Math.min(3, current + 1); updates["system.experience.learned.characteristic"] = true; } else if (c.type === "reserve") { const bonus = actor.system.experience.reserveBonus?.[c.key] || 0; updates["system.experience.reserveBonus." + c.key] = bonus + 1; updates["system.experience.learned.reserve"] = true; } else if (c.type === "evolution") { updates["system.experience.evolution"] = (actor.system.experience.evolution || 0) + 1; updates["system.experience.learned.evolution"] = true; } else if (c.type === "totem") { updates["system.experience.learned.ability"] = true; await actor.updateEmbeddedDocuments("Item", [{ _id: c.key, "system.learned": true }]); } else if (c.type === "background") { updates["system.experience.learned.background"] = true; await actor.updateEmbeddedDocuments("Item", [{ _id: c.key, "system.learned": true }]); } else if (c.type === "reputation") { const current = actor.system?.attributes?.reputation?.value ?? 0; if (c.key === "increase") { updates["system.attributes.reputation.value"] = Math.min(10, current + successes); } else { updates["system.attributes.reputation.value"] = Math.max(0, current - 1); } updates["system.experience.learned.reputation"] = true; } updates["system.experience.learned.phase"] = actor.system.experience.phase; await actor.update(updates); ui.notifications.info(game.i18n.localize("VERMINE.learning_success")); } }