From 9cf7d9ac686c1858aca95829287d802438dc6e15 Mon Sep 17 00:00:00 2001 From: LeRatierBretonnier Date: Tue, 4 Aug 2026 14:14:09 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20syst=C3=A8me=20d'exp=C3=A9rience=20et?= =?UTF-8?q?=20transferts=20de=20d=C3=A9s=20d'exp=C3=A9rience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- docs/user/COMBAT.md | 126 +++++++ .../applications/sheets/character-sheet.mjs | 44 ++- module/models/character.mjs | 42 ++- module/models/group.mjs | 5 + .../system/dialogs/experiencePhaseDialog.mjs | 150 ++++++++ module/system/dialogs/learningDialog.mjs | 327 ++++++++++++++++++ module/system/dialogs/rollDialog.mjs | 155 ++++++++- module/system/experience.mjs | 146 ++++++++ .../actor/appv2/character-experience.hbs | 71 ++++ templates/dialogs/experience-phase-dialog.hbs | 109 ++++++ templates/dialogs/learning-dialog.hbs | 94 +++++ templates/dialogs/roll-dialog.hbs | 27 ++ 12 files changed, 1277 insertions(+), 19 deletions(-) create mode 100644 docs/user/COMBAT.md create mode 100644 module/system/dialogs/experiencePhaseDialog.mjs create mode 100644 module/system/dialogs/learningDialog.mjs create mode 100644 module/system/experience.mjs create mode 100644 templates/actor/appv2/character-experience.hbs create mode 100644 templates/dialogs/experience-phase-dialog.hbs create mode 100644 templates/dialogs/learning-dialog.hbs diff --git a/docs/user/COMBAT.md b/docs/user/COMBAT.md new file mode 100644 index 0000000..c339c8a --- /dev/null +++ b/docs/user/COMBAT.md @@ -0,0 +1,126 @@ +# Combat — Échanges de coups (assisté) + +## Principe + +Chaque échange de coups se déroule dans le chat : un jet d'attaque, un éventuel +jet de défense, puis une résolution assistée. Tous les calculs (réussites, +dégâts, protection, blessure) sont effectués automatiquement ; la blessure +s'applique ensuite d'un simple clic. + +``` +Attaque → (Défense) → Résoudre → Appliquer +``` + +## 1. L'attaque + +Ouvrir le dialogue d'attaque : + +- **Personnage / PNJ** : cliquer l'icône cible (`fa-crosshairs`) sur une arme + dans l'onglet **Combat** (liste des armes équipées) ou dans l'onglet + **Matériel**. +- **Créature** : cliquer la valeur « Attaque » de l'onglet **Stats**. + +Le dialogue est prérempli selon le type d'acteur : + +| Type | Pool | Dégâts de base | Difficulté | +|------|------|----------------|------------| +| **Personnage** | Caractéristique + compétence (verrouillées) | Arme (+ Vigueur si « Vigueur+ ») | Selon maîtrise / portée | +| **PNJ** | Pool de menace + réserves de rôle | Arme équipée, sinon `Vigueur` de menace | Selon niveau d'expérience | +| **Créature** | `Attaque` calculée | `Dégâts` calculés | Son statut de combat (verrouillée) | + +### Difficulté + +- **Contact** : dépend du niveau de compétence de l'attaquant. + - Incompétent / Débutant : `7` + - Confirmé : `5 ou 7` + - Expert : `5, 7 ou 9` + - Maître / Légende : `3, 5, 7 ou 9` +- **Distance** : par portée. + - Courte : `5` — Moyenne : `7` — Longue : `9` +- **Créature ciblée** : la difficulté est forcée au statut de combat de la + créature (toute réussite ≥ 1 touche). + +### Options du dialogue + +- **Deuxième action** : +2 à la difficulté. +- **Handicap** : augmente les succès requis. +- **Sang-Froid** : dés bonus (débités du personnage). +- **Totems** : dés de totem (+ garder un totem si les deux sont actifs). +- **Consommation de munitions** : pour les armes à distance (coche cochée par + défaut, la munition est décomptée au jet). +- Les **cibles** sélectionnées sur la carte (`Ctrl` + clic / outil de ciblage) + sont affichées et reprises dans l'échange. + +### La carte de chat + +Le message d'attaque affiche l'arme (dégâts, portées, munitions) puis le bloc +**Échange de coups** avec, **par cible**, les boutons **Esquive** et **Parade** +(contact) ou **Esquive** seule (distance), ainsi que le bouton **Résoudre**. + +## 2. La défense + +Seuls les acteurs **capables de se défendre** (personnages et PNJ) ont des +boutons de défense — pas les créatures (défense passive). + +- Cliquer **Esquive** ou **Parade** sur la carte d'attaque. +- Le dialogue de défense est **verrouillé** : + - **Esquive** : `Réflexes` + `Corps-à-corps` (attaque au contact) ou + `Vigilance` (attaque à distance). + - **Parade** : `Vigueur` + `Mêlée` (ou `Corps-à-corps`). + - **Difficulté = celle de l'attaque** (non modifiable). +- Le jet de défense est lié au message d'attaque (même échange). + +> Permission : Esquive / Parade ne sont utilisables que par le **propriétaire +> de la cible** ou le **MJ**. + +## 3. La résolution + +Cliquer **Résoudre** (auteur du message ou MJ). Le système compare les +**réussites** des jets d'attaque et de défense : + +- **Attaquant > Défenseur** → **Touché** (parade insuffisante si parade). +- **Attaquant ≤ Défenseur** → **Esquivé / Paré** : aucun dégât. +- **Sans défense** (abstention ou créature) : toute réussite ≥ 1 touche. + +Les totaux sont lus en direct : une **relance** de dé effectuée avant la +résolution est prise en compte. + +### Dégâts + +``` +Dégâts = dégâts de base de l'arme (+ Vigueur si « Vigueur+ ») + réussites d'attaque +``` + +### Protection + +- **Parade** : seule la protection utilisée pour la parade réduit. +- **Sinon** : somme des **protections équipées** de la cible + (créature : protection de rôle). + +### Blessure + +La blessure correspond au **seuil le plus élevé** inférieur ou égal aux dégâts +nets : **Légère**, **Grave** ou **Mortelle**. Un bloc de résultat est affiché +**par cible** (attaque multi-cibles = un bloc par cible). + +## 4. L'application de la blessure + +Cliquer **« Appliquer la blessure »** sur le bloc de résultat (propriétaire de +la cible ou MJ) : le cercle de blessure correspondant est coché sur l'acteur. + +## Rappels et permissions + +| Action | Qui peut la faire | +|--------|-------------------| +| Esquive / Parade | Propriétaire de la cible ou MJ | +| Résoudre | Auteur du message d'attaque ou MJ | +| Appliquer la blessure | Propriétaire de la cible ou MJ | + +- Les **créatures** ne lancent pas de défense : leur difficulté d'attaque et de + défense est leur **statut de combat**. +- Les **PNJ** lancent leur défense comme un personnage (capacités + compétences). +- Les **réserves de rôle** des PNJ sont à dépenser à la main (gérées par le MJ). + +--- + +*Guide utilisateur — Vermine2047 pour FoundryVTT.* diff --git a/module/applications/sheets/character-sheet.mjs b/module/applications/sheets/character-sheet.mjs index 86b38d6..58e394d 100644 --- a/module/applications/sheets/character-sheet.mjs +++ b/module/applications/sheets/character-sheet.mjs @@ -7,18 +7,21 @@ export default class VermineCharacterSheetV2 extends VermineBaseActorSheet { position: { width: 860, height: 720 }, window: { contentClasses: ["character-content"] }, actions: { - addSpecialty: VermineCharacterSheetV2.#onAddSpecialty + addSpecialty: VermineCharacterSheetV2.#onAddSpecialty, + openExperiencePhase: VermineCharacterSheetV2.#onOpenExperiencePhase, + openLearning: VermineCharacterSheetV2.#onOpenLearning } } static PARTS = { - main: { template: "systems/vermine2047/templates/actor/appv2/character-main.hbs" }, + main: { template: "systems/vermine2047/templates/actor/appv2/character-main.hbs", scrollable: [""] }, tabs: { template: "templates/generic/tab-navigation.hbs" }, - abilities: { template: "systems/vermine2047/templates/actor/appv2/character-abilities.hbs" }, - totem: { template: "systems/vermine2047/templates/actor/appv2/character-totem.hbs" }, - equipment: { template: "systems/vermine2047/templates/actor/appv2/character-equipment.hbs" }, - stories: { template: "systems/vermine2047/templates/actor/appv2/character-stories.hbs" }, - combat: { template: "systems/vermine2047/templates/actor/appv2/character-combat.hbs" } + abilities: { template: "systems/vermine2047/templates/actor/appv2/character-abilities.hbs", scrollable: [""] }, + totem: { template: "systems/vermine2047/templates/actor/appv2/character-totem.hbs", scrollable: [""] }, + equipment: { template: "systems/vermine2047/templates/actor/appv2/character-equipment.hbs", scrollable: [""] }, + stories: { template: "systems/vermine2047/templates/actor/appv2/character-stories.hbs", scrollable: [""] }, + combat: { template: "systems/vermine2047/templates/actor/appv2/character-combat.hbs", scrollable: [""] }, + experience: { template: "systems/vermine2047/templates/actor/appv2/character-experience.hbs", scrollable: [""] } } tabGroups = { sheet: "abilities" } @@ -29,7 +32,8 @@ export default class VermineCharacterSheetV2 extends VermineBaseActorSheet { totem: { id: "totem", group: "sheet", icon: "fas fa-star", label: "VERMINE.tabs.totem" }, equipment: { id: "equipment", group: "sheet", icon: "fas fa-hammer", label: "VERMINE.tabs.equipment" }, stories: { id: "stories", group: "sheet", icon: "fas fa-book-open-reader", label: "VERMINE.tabs.stories" }, - combat: { id: "combat", group: "sheet", icon: "fas fa-medal", label: "VERMINE.tabs.combat" } + combat: { id: "combat", group: "sheet", icon: "fas fa-medal", label: "VERMINE.tabs.combat" }, + experience: { id: "experience", group: "sheet", icon: "fas fa-graduation-cap", label: "VERMINE.tabs.experience" } } for (const v of Object.values(tabs)) { v.active = this.tabGroups[v.group] === v.id @@ -74,9 +78,21 @@ export default class VermineCharacterSheetV2 extends VermineBaseActorSheet { break case "combat": context.tab = context.tabs.combat + context.equippedWeapons = doc.itemTypes.weapon.filter(i => i.system.equipped) + context.equippedDefenses = doc.itemTypes.defense.filter(i => i.system.equipped) const { prepareActiveEffectCategories } = await import("../../system/effects.mjs") context.effects = prepareActiveEffectCategories(doc.effects) break + case "experience": + context.tab = context.tabs.experience + context.specialtyCount = doc.itemTypes.specialty.length + const ex = doc.system.experience + context.experience = ex + context.collectiveTotal = (ex.collective.danger + ex.collective.discoveries + ex.collective.duration) + context.individualTotal = (ex.individual.implication + ex.individual.influence + ex.individual.interpretation) + context.currentPhase = ex.phase + context.learnedCurrent = ex.learned.phase === ex.phase + break } return context } @@ -96,4 +112,16 @@ export default class VermineCharacterSheetV2 extends VermineBaseActorSheet { if (skillKey) itemData.system = { skill: skillKey } await this.document.createEmbeddedDocuments("Item", [itemData]) } + + static async #onOpenExperiencePhase(event, target) { + const { default: ExperiencePhaseDialog } = await import("../../system/dialogs/experiencePhaseDialog.mjs") + const dialog = await ExperiencePhaseDialog.create() + if (dialog) dialog.render(true) + } + + static async #onOpenLearning(event, target) { + const { default: LearningDialog } = await import("../../system/dialogs/learningDialog.mjs") + const dialog = await LearningDialog.create({ actorId: this.document.id }) + if (dialog) dialog.render(true) + } } diff --git a/module/models/character.mjs b/module/models/character.mjs index ae64e11..fc2872c 100644 --- a/module/models/character.mjs +++ b/module/models/character.mjs @@ -72,8 +72,40 @@ export default class VermineCharacterData extends foundry.abstract.TypeDataModel attributes: new fields.SchemaField({ xp: attributeSchema(0, 0, 10), reputation: attributeSchema(0, 0, 10), - self_control: attributeSchema(0, 0, 5), - effort: attributeSchema(0, 0, 5) + self_control: attributeSchema(0, 0, 10), + effort: attributeSchema(0, 0, 10) + }), + + // Expérience (dés, récompenses, évolution, apprentissage) + experience: new fields.SchemaField({ + dice: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0, min: 0 }), + phase: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0, min: 0 }), + collective: new fields.SchemaField({ + danger: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0, min: 0, max: 3 }), + discoveries: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0, min: 0, max: 3 }), + duration: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0, min: 0, max: 3 }) + }), + individual: new fields.SchemaField({ + implication: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0, min: 0, max: 3 }), + influence: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0, min: 0, max: 3 }), + interpretation: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0, min: 0, max: 3 }) + }), + evolution: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0, min: 0 }), + traumaReward: new fields.BooleanField({ required: true, initial: false }), + // Bonus de Réserve acquis par apprentissage (s'ajoute au max dérivé) + reserveBonus: new fields.SchemaField({ + self_control: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0, min: 0 }), + effort: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0, min: 0 }) + }), + // Limites d'apprentissage par phase + learned: new fields.SchemaField({ + phase: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0, min: 0 }), + skill: new fields.BooleanField({ required: true, initial: false }), + specialty: new fields.BooleanField({ required: true, initial: false }), + reserve: new fields.BooleanField({ required: true, initial: false }), + characteristic: new fields.BooleanField({ required: true, initial: false }), + evolution: new fields.BooleanField({ required: true, initial: false }) + }) }), // Rencontres @@ -127,7 +159,8 @@ export default class VermineCharacterData extends foundry.abstract.TypeDataModel const sum = abilities .filter(a => a.category === "mental" || a.category === "social") .reduce((acc, a) => acc + a.value, 0) - this.attributes.self_control.max = sum + modFromAge + const bonus = this.experience?.reserveBonus?.self_control || 0 + this.attributes.self_control.max = Math.min(10, sum + modFromAge + bonus) } /** @@ -140,7 +173,8 @@ export default class VermineCharacterData extends foundry.abstract.TypeDataModel const sum = abilities .filter(a => a.category === "physical" || a.category === "manual") .reduce((acc, a) => acc + a.value, 0) - this.attributes.effort.max = sum + modFromAge + const bonus = this.experience?.reserveBonus?.effort || 0 + this.attributes.effort.max = Math.min(10, sum + modFromAge + bonus) } /** diff --git a/module/models/group.mjs b/module/models/group.mjs index 1ecb4db..a12133e 100644 --- a/module/models/group.mjs +++ b/module/models/group.mjs @@ -75,6 +75,11 @@ export default class VermineGroupData extends foundry.abstract.TypeDataModel { // Membres (IDs d'acteurs) members: new fields.ArrayField(new fields.StringField({ required: true, nullable: false, initial: "" })), + // Dés d'Expérience collectifs (réserve de Groupe) + experience: new fields.SchemaField({ + dice: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0, min: 0 }) + }), + // Rencontres encounters: new fields.ArrayField(new fields.StringField({ required: true, nullable: false, initial: "" })) } diff --git a/module/system/dialogs/experiencePhaseDialog.mjs b/module/system/dialogs/experiencePhaseDialog.mjs new file mode 100644 index 0000000..6499f14 --- /dev/null +++ b/module/system/dialogs/experiencePhaseDialog.mjs @@ -0,0 +1,150 @@ +const { HandlebarsApplicationMixin } = foundry.applications.api; + +/** + * Dialogue du meneur pour ouvrir une phase d'Expérience. + * Permet d'attribuer les Dés d'Expérience collectifs (Danger, Découvertes, + * Durée) au Groupe et aux personnages, ainsi que les Dés individuels + * (Implication, Influence, Interprétation), les récompenses d'Objectifs et + * la récompense de Traumatisme choisi. + */ +export default class ExperiencePhaseDialog extends HandlebarsApplicationMixin(foundry.applications.api.ApplicationV2) { + + static DEFAULT_OPTIONS = { + classes: ["vermine-roll"], + tag: "form", + window: { + icon: "fas fa-star", + resizable: true + }, + position: { + width: 620, + height: 700 + }, + actions: { + apply: ExperiencePhaseDialog.#onApply, + cancel: ExperiencePhaseDialog.#onCancel + } + }; + + static PARTS = { + main: { template: "systems/vermine2047/templates/dialogs/experience-phase-dialog.hbs", scrollable: [""] } + }; + + static async create() { + return new ExperiencePhaseDialog(); + } + + get title() { + return game.i18n.localize("VERMINE.experience_phase"); + } + + async _prepareContext() { + const characters = game.actors.filter(a => a.type === "character"); + const groups = game.actors.filter(a => a.type === "group"); + return { + characters: characters.map(a => ({ + id: a.id, + name: a.name, + dice: a.system?.experience?.dice || 0 + })), + groups: groups.map(a => ({ + id: a.id, + name: a.name, + dice: a.system?.experience?.dice || 0 + })), + rewards: [ + { value: 0, label: game.i18n.localize("VERMINE.reward_anecdotic") }, + { value: 1, label: game.i18n.localize("VERMINE.reward_minor") }, + { value: 2, label: game.i18n.localize("VERMINE.reward_major") }, + { value: 3, label: game.i18n.localize("VERMINE.reward_exceptionnal") } + ] + }; + } + + async _onRender(context, options) { + this.element.querySelectorAll("[data-roll]").forEach(inp => inp.addEventListener("change", () => this.#updateUI())); + this.#updateUI(); + } + + #updateUI() { + const danger = parseInt(this.element.querySelector("#collective-danger")?.value, 10) || 0; + const discoveries = parseInt(this.element.querySelector("#collective-discoveries")?.value, 10) || 0; + const duration = parseInt(this.element.querySelector("#collective-duration")?.value, 10) || 0; + const el = this.element.querySelector("#collective-total"); + if (el) el.textContent = `${danger + discoveries + duration}D`; + } + + static async #onCancel(event, target) { + this.close(); + } + + static async #onApply(event, target) { + const collective = (parseInt(this.element.querySelector("#collective-danger")?.value, 10) || 0) + + (parseInt(this.element.querySelector("#collective-discoveries")?.value, 10) || 0) + + (parseInt(this.element.querySelector("#collective-duration")?.value, 10) || 0); + + const groupId = this.element.querySelector("#group-select")?.value || null; + if (groupId) { + const group = game.actors.get(groupId); + if (group && collective > 0) { + const current = group.system?.experience?.dice || 0; + await group.update({ "system.experience.dice": current + collective }); + } + } + + const results = []; + for (const row of this.element.querySelectorAll("[data-character-id]")) { + const id = row.dataset.characterId; + const actor = game.actors.get(id); + if (!actor) continue; + const implication = parseInt(row.querySelector(`[name="implication-${id}"]`)?.value, 10) || 0; + const influence = parseInt(row.querySelector(`[name="influence-${id}"]`)?.value, 10) || 0; + const interpretation = parseInt(row.querySelector(`[name="interpretation-${id}"]`)?.value, 10) || 0; + const objectiveMinor = row.querySelector(`[name="objective-minor-${id}"]`)?.checked ? 3 : 0; + const objectiveMajor = row.querySelector(`[name="objective-major-${id}"]`)?.checked ? 5 : 0; + const trauma = row.querySelector(`[name="trauma-${id}"]`)?.checked ? 5 : 0; + + const individual = implication + influence + interpretation; + const bonus = objectiveMinor + objectiveMajor + trauma; + const gained = collective + individual + bonus; + + const ex = actor.system?.experience; + const nextPhase = (ex?.phase || 0) + 1; + const update = { + "system.experience.phase": nextPhase, + "system.experience.dice": (ex?.dice || 0) + gained, + "system.experience.collective.danger": parseInt(this.element.querySelector("#collective-danger")?.value, 10) || 0, + "system.experience.collective.discoveries": parseInt(this.element.querySelector("#collective-discoveries")?.value, 10) || 0, + "system.experience.collective.duration": parseInt(this.element.querySelector("#collective-duration")?.value, 10) || 0, + "system.experience.individual.implication": implication, + "system.experience.individual.influence": influence, + "system.experience.individual.interpretation": interpretation, + "system.experience.traumaReward": trauma > 0, + "system.experience.learned.phase": nextPhase, + "system.experience.learned.skill": false, + "system.experience.learned.specialty": false, + "system.experience.learned.reserve": false, + "system.experience.learned.characteristic": false, + "system.experience.learned.evolution": false + }; + await actor.update(update); + results.push({ name: actor.name, gained }); + } + + const lines = [`${game.i18n.localize("VERMINE.experience_phase")} +1`]; + if (groupId) { + const group = game.actors.get(groupId); + lines.push(`${group?.name ?? ""} (${game.i18n.localize("VERMINE.collective")}): +${collective}D`); + } + for (const r of results) { + lines.push(`${r.name}: +${r.gained}D`); + } + await ChatMessage.create({ + user: game.user?._id, + speaker: ChatMessage.getSpeaker(), + content: `

${game.i18n.localize("VERMINE.experience_rewards")}

${lines.map(l => `${l}`).join("")}
` + }); + + this.close(); + } +} diff --git a/module/system/dialogs/learningDialog.mjs b/module/system/dialogs/learningDialog.mjs new file mode 100644 index 0000000..5359888 --- /dev/null +++ b/module/system/dialogs/learningDialog.mjs @@ -0,0 +1,327 @@ +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) + })); + 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 + }; + } + + async _onRender(context, options) { + 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 ?? ""; + return this.#el.querySelector("#learning-target")?.value ?? ""; + } + + #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(); + const difficulty = 7; + let handicap = 0; + let poolModifier = 0; + 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, + label, + targetLabel: key ? game.i18n.localize(key === "self_control" ? "VERMINE.self_control" : "VERMINE.effort") : "" + }; + } + // 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), + 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"); + + // 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); + set("#pool-value", `${pool}D${c.poolModifier ? ` (${c.poolModifier >= 0 ? "+" : ""}${c.poolModifier}D)` : ""}`); + const poolInput = this.#el.querySelector("#learning-dice"); + if (poolInput) poolInput.max = this.#actor.system?.experience?.dice || 0; + } + + static async #onCancel(event, target) { + this.close(); + } + + static async #onRoll(event, target) { + 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 et + // un seul Dé d'Évolution par phase. + if (ex?.learned?.phase === ex?.phase) { + const flagMap = { skill: "skill", specialty: "specialty", reserve: "reserve", characteristic: "characteristic", evolution: "evolution" }; + 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; + } + + 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, + 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, spent, specialtyName); + } + this.close(); + } + + async #applySuccess(c, spent, specialtyName) { + const actor = this.#actor; + const updates = { "system.experience.dice": (actor.system.experience.dice || 0) - spent }; + 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 { + updates["system.experience.evolution"] = (actor.system.experience.evolution || 0) + 1; + updates["system.experience.learned.evolution"] = true; + } + updates["system.experience.learned.phase"] = actor.system.experience.phase; + await actor.update(updates); + ui.notifications.info(game.i18n.localize("VERMINE.learning_success")); + } +} diff --git a/module/system/dialogs/rollDialog.mjs b/module/system/dialogs/rollDialog.mjs index 453ed90..5e2bf54 100644 --- a/module/system/dialogs/rollDialog.mjs +++ b/module/system/dialogs/rollDialog.mjs @@ -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(); diff --git a/module/system/experience.mjs b/module/system/experience.mjs new file mode 100644 index 0000000..b53d9b9 --- /dev/null +++ b/module/system/experience.mjs @@ -0,0 +1,146 @@ +/** + * Règles d'apprentissage (phase d'Expérience). + * Helpers purs de difficultés, handicaps et calculs de jets d'Expérience. + */ + +export class VermineExperience { + + /** + * Difficulté d'un jet d'apprentissage de Compétence selon le niveau visé. + * @param {number} targetLevel niveau visé (1-5) + * @returns {number} + */ + static skillLearningDifficulty(targetLevel) { + switch (parseInt(targetLevel, 10)) { + case 1: return 5 // Débutant + case 2: return 7 // Confirmé + case 3: return 9 // Expert + case 4: return 10 // Maître + case 5: return 10 // Légende + default: return 7 + } + } + + /** + * Handicap d'un jet d'apprentissage de Compétence. + * Rareté (1-3) + handicap (I) supplémentaire pour le niveau Légende. + * @param {number} targetLevel niveau visé (1-5) + * @param {number} rarity rareté de la compétence (0-3) + * @returns {number} + */ + static skillLearningHandicap(targetLevel, rarity) { + let handicap = parseInt(rarity, 10) || 0 + if (parseInt(targetLevel, 10) === 5) handicap += 1 // Légende : (I) + return handicap + } + + /** + * Difficulté d'un jet d'apprentissage de Spécialité selon le niveau actuel + * de la compétence associée. Domaine de prédilection : -2. + * @param {number} skillLevel niveau actuel de la compétence (2-5) + * @param {boolean} isDomain la compétence est dans le domaine de prédilection + * @returns {number} + */ + static specialtyLearningDifficulty(skillLevel, isDomain) { + let difficulty + switch (parseInt(skillLevel, 10)) { + case 2: difficulty = 9 // Confirmé + break + case 3: difficulty = 7 // Expert + break + case 4: difficulty = 5 // Maître + break + case 5: difficulty = 5 // Légende (relance automatique) + break + default: difficulty = 9 + } + return isDomain ? difficulty - 2 : difficulty + } + + /** + * Handicap d'un jet d'apprentissage de Spécialité : nombre de spécialités + * déjà possédées (requiert au moins Confirmé dans la compétence associée). + * @param {number} specialtyCount + * @returns {number} + */ + static specialtyLearningHandicap(specialtyCount) { + return Math.max(0, parseInt(specialtyCount, 10) || 0) + } + + /** + * Difficulté d'un jet d'apprentissage de Caractéristique. + * @returns {number} + */ + static characteristicLearningDifficulty() { + return 9 + } + + /** + * Difficulté d'un jet d'apprentissage de Réserve : valeur actuelle. + * @param {number} currentValue + * @returns {number} + */ + static reserveLearningDifficulty(currentValue) { + return Math.max(0, parseInt(currentValue, 10) || 0) + } + + /** + * Handicap d'âge pour l'apprentissage des Réserves et autres jets liés à l'âge. + * @param {number} ageType 1=Jeune, 2=Adulte, 3=Ancien + * @returns {number} + */ + static ageHandicap(ageType) { + switch (parseInt(ageType, 10)) { + case 1: return 1 // Jeune (I) + case 3: return 3 // Ancien (III) + default: return 2 // Adulte (II) + } + } + + /** + * Difficulté d'un jet d'acquisition d'un Dé d'Évolution selon l'âge. + * @param {number} ageType 1=Jeune, 2=Adulte, 3=Ancien + * @returns {number} + */ + static evolutionDifficulty(ageType) { + switch (parseInt(ageType, 10)) { + case 1: return 5 + case 3: return 9 + default: return 7 + } + } + + /** + * Bonus/Malus de Totem sur le jet de Dé d'Évolution. + * Plus de Dés Adaptés que Humains : +1D. Inversement : -1D. + * @param {number} human human totem dice + * @param {number} adapted adapted totem dice + * @returns {number} + */ + static evolutionTotemModifier(human, adapted) { + if (adapted > human) return 1 + if (human > adapted) return -1 + return 0 + } + + /** + * Clé i18n du libellé du niveau de compétence visé. + * @param {number} level + * @returns {string} + */ + static skillLevelLabelKey(level) { + return CONFIG.VERMINE.SkillLevels?.[level]?.label ?? "SKILL_LEVELS.incompetent" + } + + /** + * Détermine si une compétence appartient au domaine de prédilection. + * @param {Actor} actor + * @param {string} skillKey + * @returns {boolean} + */ + static isPreferredDomain(actor, skillKey) { + const category = actor.system?.skills?.[skillKey]?.category + const preferred = actor.system?.skill_categories?.preferred + return Boolean(category && preferred && category === preferred) + } +} diff --git a/templates/actor/appv2/character-experience.hbs b/templates/actor/appv2/character-experience.hbs new file mode 100644 index 0000000..6c6a52e --- /dev/null +++ b/templates/actor/appv2/character-experience.hbs @@ -0,0 +1,71 @@ +
+

{{ localize 'VERMINE.experience' }}

+ +
+
+ + {{ system.experience.dice }}D +
+
+ + {{ system.experience.phase }} +
+
+ + {{ system.experience.evolution }}D +
+
+ +
+ {{#if isGM}} + + {{/if}} + +
+ +
+

{{ localize 'VERMINE.collective_rewards' }} ({{ collectiveTotal }}D)

+
+ + {{ system.experience.collective.danger }}D + + {{ system.experience.collective.discoveries }}D + + {{ system.experience.collective.duration }}D +
+
+ +
+

{{ localize 'VERMINE.individual_rewards' }} ({{ individualTotal }}D)

+
+ + {{ system.experience.individual.implication }}D + + {{ system.experience.individual.influence }}D + + {{ system.experience.individual.interpretation }}D +
+
+ +
+

{{ localize 'VERMINE.learning_limits' }}

+
+ {{ localize 'VERMINE.learned_skill' }} + {{ localize 'VERMINE.learned_specialty' }} + {{ localize 'VERMINE.learned_reserve' }} + {{ localize 'VERMINE.learned_characteristic' }} + {{ localize 'VERMINE.learned_evolution' }} +
+
+ +
+

{{ localize 'VERMINE.trauma_reward' }}

+
+ {{ localize 'VERMINE.trauma_reward_5' }} +
+
+
diff --git a/templates/dialogs/experience-phase-dialog.hbs b/templates/dialogs/experience-phase-dialog.hbs new file mode 100644 index 0000000..f28e7d0 --- /dev/null +++ b/templates/dialogs/experience-phase-dialog.hbs @@ -0,0 +1,109 @@ +
+

{{ localize 'VERMINE.experience_phase' }}

+ +
+ + {{ localize 'VERMINE.collective_rewards' }} + 0D + +
+
+ + +
+
+ + +
+
+ + +
+
+ {{#if groups.length}} +
+ + +
+ {{/if}} +
+ +
+ {{#each characters}} +
+ + {{ this.name }} + {{ localize 'VERMINE.experience_dice' }}: {{ this.dice }}D + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+ {{else}} +

{{ localize 'VERMINE.no_character' }}

+ {{/each}} +
+ +
+ + +
+
diff --git a/templates/dialogs/learning-dialog.hbs b/templates/dialogs/learning-dialog.hbs new file mode 100644 index 0000000..bdb1b9b --- /dev/null +++ b/templates/dialogs/learning-dialog.hbs @@ -0,0 +1,94 @@ +
+

{{ localize 'VERMINE.open_learning' }}

+ +
+ {{ localize 'VERMINE.experience_dice' }}: + {{ dice }}D +
+ +
+ + + + + +
+ +
+ + +
+ + + + + + + + + +
+ + + D +
+ +
+
+ {{ localize 'VERMINE.difficulty' }} + 5 +
+
+ {{ localize 'VERMINE.handicap' }} + 0 +
+
+ {{ localize 'VERMINE.success_required' }} + 1 +
+
+ {{ localize 'VERMINE.pool_total' }} + 1D +
+
+ +
+ + +
+
diff --git a/templates/dialogs/roll-dialog.hbs b/templates/dialogs/roll-dialog.hbs index 82ac445..02cf667 100644 --- a/templates/dialogs/roll-dialog.hbs +++ b/templates/dialogs/roll-dialog.hbs @@ -335,6 +335,33 @@ + + {{#if experience.hasExperience}} +
+ + {{localize 'VERMINE.experience_transfer'}} + {{localize 'VERMINE.experience_dice'}}: {{experience.individual}}D + +
+
+ + + {{localize 'VERMINE.collective_group'}}: {{experience.groupPool}}D +
+
+ +
+
+
+ {{/if}} +