Files
vermine2047/module/system/dialogs/learningDialog.mjs
T
uberwald 9d36b92fa9 feat: nouveaux apprentissages (Capacités de Totem, Historique, Réputation) et expérience de Groupe
- Dialogue d'apprentissage personnage : 4 nouveaux types d'achat.
  - Capacité de Totem : options filtrées (mode de jeu, niveau de
    groupe, totem, non-apprises), difficulté = valeur d'Apprentissage,
    limites 3×N1 / 1×N2-N3 / max 5, marque system.learned de l'item
    (convention type character) et learned.ability du personnage.
  - Élément d'Historique : difficulté 9, handicap 1 si astérisque
    (background.asterisk), marque learned.background + item.
  - Test de Réputation (réduire/augmenter) : réduire = difficulté
    10 − score visé (−1 pt), augmenter = difficulté = réputation
    actuelle (+1 par succès), bornes 0-10, marque learned.reputation.
  - Évolution : option Dé de Totem Adapté ×2 quand Adapté > Humain
    (1D remplacé par un dé adapté compté ×2 via VermineUtils.roll).
- Correction règles : les Dés d'Expérience sont dépensés même en cas
  d'échec du jet d'apprentissage (décompte hors #applySuccess).
- Expérience de Groupe : nouveau dialogue d'apprentissage de Groupe
  (niveau, réserve, capacité de totem), réserve d'expérience et phase
  dans system.experience, boutons sur la fiche Groupe, reset des
  limites par phase (level/reserve/ability).
- Phase d'expérience : filtre par groupe sélectionné, associe chaque
  personnage à son premier groupe, reset des limites de tous les
  nouveaux types d'apprentissage.
- Corrections : l'attaque de créature n'ajoute plus la Réaction du Rôle
  (regles_creatures.txt), suppression de la case XP des en-têtes PJ
  (legacy et AppV2).
- Divers : .eslintrc.js (règles inexistantes/obsolètes retirées),
  libellé jaune de dialogue en couleur chitine, i18n FR/EN, chips
  « appris » sur l'onglet expérience du personnage.
2026-08-05 11:56:59 +02:00

539 lines
21 KiB
JavaScript

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() {
const items = (this.#actor.itemTypes?.ability || []).filter(i => i.system?.type === "character" && i.system?.learned);
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"));
}
}