feat: système d'expérience et transferts de dés d'expérience
Release Creation / build (release) Failing after 1m20s

- 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)
This commit is contained in:
2026-08-04 14:14:09 +02:00
parent f2c8cfe246
commit 9cf7d9ac68
12 changed files with 1277 additions and 19 deletions
+148 -7
View File
@@ -23,7 +23,8 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
},
actions: {
roll: RollDialog.#onRoll,
cancel: RollDialog.#onCancel
cancel: RollDialog.#onCancel,
giveToGroup: RollDialog.#onGiveToGroup
}
};
@@ -32,6 +33,9 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
};
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"));
@@ -42,7 +46,7 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
ui.notifications.warn(game.i18n.localize("VERMINE.error_no_actor_selected"));
return null;
}
return new RollDialog({ actor, label: data.label, rolltype: data.rolltype });
return new RollDialog({ actor, label: data.label, rolltype: data.rolltype, weapon: data.weapon, locks: data.locks, defense: data.defense });
}
constructor(options = {}) {
@@ -50,10 +54,16 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
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,
@@ -66,13 +76,28 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
help: false,
specialty: false,
availableSpecialties: actor.items.filter(i => i.type === "specialty"),
availableItems: actor.items.filter(i => i.type === "item")
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));
}
@@ -95,6 +120,7 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
this.element.querySelector("#adapted-totem")?.addEventListener("change", () => this.#updateUI());
this.#displaySpecialties();
this.#refreshExperienceUI();
this.#updateUI();
if (ability?.value !== "0") {
@@ -111,6 +137,16 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
#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() {
@@ -124,11 +160,12 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
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;
return { ability, skill: skillPool, selfControl, specialty, help, tooling, group, total };
const total = ability + skillPool + selfControl + specialty + help + tooling + group + experience;
return { ability, skill: skillPool, selfControl, specialty, help, tooling, group, experience, total };
}
getDicePool() {
@@ -214,6 +251,40 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
// ── 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";
@@ -222,7 +293,31 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
#calculateBonusCount() {
const b = this.getPoolBreakdown();
return b.specialty + b.help + b.tooling + b.group + b.selfControl;
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() {
@@ -289,6 +384,29 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
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) {
@@ -310,6 +428,26 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
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(),
@@ -325,7 +463,10 @@ export default class RollDialog extends HandlebarsApplicationMixin(foundry.appli
skillLevel: this.getSkillLevel(),
hasSpecialty: this.hasSpecialtySelected(),
poolBreakdown: this.getPoolBreakdown(),
specialtyName: this.getSpecialtyName()
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();