diff --git a/.eslintrc.js b/.eslintrc.js index 73e50f3..159069a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -86,7 +86,6 @@ module.exports = { 'default-case': 'warn', 'default-case-last': 'warn', 'default-param-last': 'warn', - 'dot-locale-compare': 'warn', 'dot-notation': ['warn', { allowKeywords: true }], 'eqeqeq': ['error', 'always', { null: 'ignore' }], 'func-name-matching': 'warn', @@ -123,7 +122,6 @@ module.exports = { 'no-delete-var': 'error', 'no-div-regex': 'warn', 'no-else-return': ['warn', { allowElseIf: true }], - 'no-empty-destructuring': 'warn', 'no-empty-function': ['warn', { allow: ['constructors'] }], 'no-empty-pattern': 'warn', 'no-eq-null': 'off', @@ -153,7 +151,6 @@ module.exports = { 'no-new-func': 'warn', 'no-new-wrappers': 'error', 'no-nonoctal-decimal-escape': 'error', - 'no-object-multi-space': 'warn', 'no-octal-escape': 'error', 'no-param-reassign': ['warn', { props: false }], 'no-plusplus': 'off', @@ -267,10 +264,9 @@ module.exports = { VariableDeclarator: { var: 2, let: 2, const: 3 }, outerIIFEBody: 1, MemberExpression: 'off', - FunctionDeclaration: { body: 1, parameters: 1, parameters: { var: 2, let: 2, const: 3 } }, - FunctionExpression: { body: 1, parameters: 1, parameters: { var: 2, let: 2, const: 3 } }, - StaticBlock: { body: 1 }, - ClassBody: 1 + FunctionDeclaration: { body: 1, parameters: 1 }, + FunctionExpression: { body: 1, parameters: 1 }, + StaticBlock: { body: 1 } }], 'jsx-quotes': 'off', 'key-spacing': ['warn', { beforeColon: false, afterColon: true, mode: 'strict' }], diff --git a/lang/en.json b/lang/en.json index 06af754..8202072 100644 --- a/lang/en.json +++ b/lang/en.json @@ -381,6 +381,9 @@ "learned_reserve": "Reserve", "learned_characteristic": "Characteristic", "learned_evolution": "Evolution", + "learned_ability": "Totem capacity", + "learned_background": "Background", + "learned_reputation": "Reputation", "no_character": "No character in this world.", "apply": "Apply", "characteristic": "Characteristic", @@ -389,11 +392,22 @@ "learn_characteristic": "Characteristic", "learn_reserve": "Reserve", "learn_evolution": "Evolution die", + "learn_totem_ability": "Totem capacity", + "learn_background": "Background element", + "learn_reputation": "Reputation", + "learn_adapted_die": "Replace 1 Experience die with an Adapted totem die (×2)", + "adapted_die_x2": "1 Adapted totem die ×2", "learn_specialty_name": "Specialty name", "specialty_hint": "Prerequisite: skill at least Proficient (≥2).", "characteristic_hint": "Difficulty 9 + Handicap = current value. Max 3 (except Superhuman).", "reserve_hint": "Difficulty = current value + age Handicap. Max 10.", "evolution_hint": "Difficulty by age + Handicap = accumulated Evolution dice. Adapted totem +1D, Human -1D.", + "totem_ability_hint": "Prerequisites: same Totem as the character, Group level ≥ Capacity level, game mode compatible (Survival N1, Nightmare N2, Apocalypse N3).", + "background_hint": "Difficulty 9. Handicap (I) if the Background is marked with an asterisk (*). GM authorization required.", + "reputation_hint": "Reduce = more famous (Difficulty 10 − target score, 1 pt max per phase). Increase = fade into obscurity (requires a major Objective, Difficulty = current Reputation, +1 per Success).", + "reputation_reduce": "Become more famous (reduce)", + "reputation_increase": "Fade into obscurity (increase)", + "reputation_current": "Current Reputation", "dice_spent": "Dice spent", "learning_success": "Learning successful!", "error_no_experience_dice": "No Experience dice available.", @@ -402,6 +416,25 @@ "error_characteristic_max": "Characteristic already at maximum (3).", "error_specialty_requirement": "Specialty requires: skill at least Proficient (≥2).", "error_reserve_max": "Reserve already at maximum (10).", + "no_totem_ability": "No Totem capacity available to learn.", + "no_background": "No background element to acquire.", + "error_totem_mode": "Capacity level incompatible with the game mode.", + "error_totem_totem": "The character must share the Totem of the Capacity.", + "error_totem_group_level": "Group level insufficient for this Capacity.", + "error_totem_limit": "Totem capacity limit reached (3× Level 1, 1× Levels 2 and 3, max 5).", + "error_reputation_min": "Reputation already at minimum (0).", + "error_reputation_max": "Reputation already at maximum (10).", + "error_evolution_no_adapted": "The character must have strictly more Adapted than Human totem dice.", + "learn_level": "Group level", + "learn_group_reserve": "Group reserve", + "learn_ability": "Group capacity", + "group_level_hint": "Difficulty = number of members. Handicap = current Group level. Max 10.", + "group_reserve_hint": "Difficulty = current Reserve value. Handicap II. Max 10.", + "group_ability_hint": "Difficulty = Learning. Handicap = Capacity level. Requires Group level ≥ Capacity level.", + "no_group_ability": "No Group capacity available to develop.", + "error_group_level_max": "Group level already at maximum (10).", + "error_group_reserve_max": "Group reserve already at maximum.", + "error_group_level_requirement": "Group level insufficient for this Capacity.", "experience_transfer": "Experience dice", "experience_pull": "Pull from group", "experience_give": "Give 1D to group", diff --git a/lang/fr.json b/lang/fr.json index bd9c146..a83a522 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -364,6 +364,9 @@ "learned_reserve": "Réserve", "learned_characteristic": "Caractéristique", "learned_evolution": "Évolution", + "learned_ability": "Capacité de Totem", + "learned_background": "Historique", + "learned_reputation": "Réputation", "no_character": "Aucun personnage dans ce monde.", "apply": "Appliquer", "characteristic": "Caractéristique", @@ -372,11 +375,22 @@ "learn_characteristic": "Caractéristique", "learn_reserve": "Réserve", "learn_evolution": "Dé d'Évolution", + "learn_totem_ability": "Capacité de Totem", + "learn_background": "Élément d'Historique", + "learn_reputation": "Réputation", + "learn_adapted_die": "Remplacer 1D d'Expérience par un Dé de Totem Adapté (×2)", + "adapted_die_x2": "1 Dé de Totem Adapté ×2", "learn_specialty_name": "Nom de la spécialité", "specialty_hint": "Prérequis : compétence au moins Confirmée (≥2).", "characteristic_hint": "Difficulté 9 + Handicap = valeur actuelle. Max 3 (Surhumain excepté).", "reserve_hint": "Difficulté = valeur actuelle + Handicap d'âge. Max 10.", "evolution_hint": "Difficulté selon l'âge + Handicap = Dés d'Évolution accumulés. Totem Adapté +1D, Humain -1D.", + "totem_ability_hint": "Prérequis : même Totem que le personnage, Niveau de Groupe ≥ Niveau de la Capacité, Mode de jeu compatible (Survie N1, Cauchemar N2, Apocalypse N3).", + "background_hint": "Difficulté 9. Handicap (I) si l'Historique est marqué d'un astérisque (*). Autorisation du MJ requise.", + "reputation_hint": "Réduire = plus célèbre (Difficulté 10 − score visé, 1 pt max/phase). Augmenter = se faire oublier (exige un Objectif majeur, Difficulté = Réputation actuelle, +1 par Réussite).", + "reputation_reduce": "Devenir plus célèbre (réduire)", + "reputation_increase": "Se faire oublier (augmenter)", + "reputation_current": "Réputation actuelle", "dice_spent": "Dés dépensés", "learning_success": "Apprentissage réussi !", "error_no_experience_dice": "Pas de Dés d'Expérience disponibles.", @@ -385,6 +399,25 @@ "error_characteristic_max": "Caractéristique déjà au maximum (3).", "error_specialty_requirement": "Spécialité requise : compétence au moins Confirmée (≥2).", "error_reserve_max": "Réserve déjà au maximum (10).", + "no_totem_ability": "Aucune Capacité de Totem disponible à apprendre.", + "no_background": "Aucun élément d'Historique à acquérir.", + "error_totem_mode": "Niveau de Capacité incompatible avec le Mode de jeu.", + "error_totem_totem": "Le personnage doit être du même Totem que la Capacité.", + "error_totem_group_level": "Niveau de Groupe insuffisant pour cette Capacité.", + "error_totem_limit": "Limite de Capacités de Totem atteinte (3× Niveau 1, 1× Niveaux 2 et 3, max 5).", + "error_reputation_min": "Réputation déjà au minimum (0).", + "error_reputation_max": "Réputation déjà au maximum (10).", + "error_evolution_no_adapted": "Il faut strictement plus de Dés du Totem Adapté que Humains.", + "learn_level": "Niveau de Groupe", + "learn_group_reserve": "Réserve de Groupe", + "learn_ability": "Capacité de Groupe", + "group_level_hint": "Difficulté = nombre de membres. Handicap = Niveau actuel du Groupe. Max 10.", + "group_reserve_hint": "Difficulté = valeur actuelle de la Réserve. Handicap II. Max 10.", + "group_ability_hint": "Difficulté = Apprentissage. Handicap = Niveau de la Capacité. Nécessite un Niveau de Groupe ≥ Niveau de la Capacité.", + "no_group_ability": "Aucune Capacité de Groupe disponible à développer.", + "error_group_level_max": "Niveau de Groupe déjà au maximum (10).", + "error_group_reserve_max": "Réserve de Groupe déjà au maximum.", + "error_group_level_requirement": "Niveau de Groupe insuffisant pour cette Capacité.", "experience_transfer": "Dés d'Expérience", "experience_pull": "Puiser du Groupe", "experience_give": "Donner 1D au Groupe", diff --git a/less/dialogs.less b/less/dialogs.less index a4090bd..cf79c7d 100644 --- a/less/dialogs.less +++ b/less/dialogs.less @@ -273,7 +273,7 @@ iframe { label { font-size: @font-size-11; - color: @color-amber; + color: @color-chitin-dark; } } } diff --git a/module/applications/sheets/group-sheet.mjs b/module/applications/sheets/group-sheet.mjs index f3c3466..ff99c8b 100644 --- a/module/applications/sheets/group-sheet.mjs +++ b/module/applications/sheets/group-sheet.mjs @@ -13,7 +13,9 @@ export default class VermineGroupSheetV2 extends VermineBaseActorSheet { deleteEncounter: VermineGroupSheetV2.#onDeleteEncounter, deleteObjective: VermineGroupSheetV2.#onDeleteObjective, addObjective: VermineGroupSheetV2.#onAddObjective, - createTotemAbility: VermineGroupSheetV2.#onCreateTotemAbility + createTotemAbility: VermineGroupSheetV2.#onCreateTotemAbility, + openLearning: VermineGroupSheetV2.#onOpenLearning, + openExperiencePhase: VermineGroupSheetV2.#onOpenExperiencePhase } } @@ -158,4 +160,16 @@ export default class VermineGroupSheetV2 extends VermineBaseActorSheet { const name = game.i18n.localize("ITEMS.new_ability") await this.document.createEmbeddedDocuments("Item", [{ name, type: "ability", system: { type: "totem" } }]) } + + 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: GroupLearningDialog } = await import("../../system/dialogs/groupLearningDialog.mjs") + const dialog = await GroupLearningDialog.create({ actorId: this.document.id }) + if (dialog) dialog.render(true) + } } diff --git a/module/models/ability.mjs b/module/models/ability.mjs index 39454f8..7e82348 100644 --- a/module/models/ability.mjs +++ b/module/models/ability.mjs @@ -17,6 +17,7 @@ export default class VermineAbilityData extends foundry.abstract.TypeDataModel { totem: new fields.StringField({ required: true, initial: "" }), learn: learnSchema(), level: levelSchema(1, 1, 3), + learned: new fields.BooleanField({ required: true, initial: false }), effects: new fields.ArrayField(new fields.StringField({ required: true, initial: "" }), { initial: [] }) diff --git a/module/models/background.mjs b/module/models/background.mjs index 3505e92..36bb23c 100644 --- a/module/models/background.mjs +++ b/module/models/background.mjs @@ -14,7 +14,9 @@ export default class VermineBackgroundData extends foundry.abstract.TypeDataMode const reqInt = { required: true, nullable: false, integer: true } return { ...listItemSchema(), - cost: new fields.NumberField({ ...reqInt, initial: 1, min: 0 }) + cost: new fields.NumberField({ ...reqInt, initial: 1, min: 0 }), + asterisk: new fields.BooleanField({ required: true, initial: false }), + learned: new fields.BooleanField({ required: true, initial: false }) } } } diff --git a/module/models/character.mjs b/module/models/character.mjs index fc2872c..59bf6ce 100644 --- a/module/models/character.mjs +++ b/module/models/character.mjs @@ -104,7 +104,10 @@ export default class VermineCharacterData extends foundry.abstract.TypeDataModel 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 }) + evolution: new fields.BooleanField({ required: true, initial: false }), + ability: new fields.BooleanField({ required: true, initial: false }), + background: new fields.BooleanField({ required: true, initial: false }), + reputation: new fields.BooleanField({ required: true, initial: false }) }) }), diff --git a/module/models/creature.mjs b/module/models/creature.mjs index 4587f17..72f867f 100644 --- a/module/models/creature.mjs +++ b/module/models/creature.mjs @@ -90,7 +90,7 @@ export default class VermineCreatureData extends foundry.abstract.TypeDataModel * .creatureRoleLevels, .creaturePackLevels. * * Règles : - * - Attaque = pattern.attack + size.attack + pack.attack + role.reaction + * - Attaque = pattern.attack + size.attack + pack.attack * - Dégâts = pattern.damage + size.vigor + pack.damage * - Vigueur = size.vigor + pack.damage * - Réaction = role.reaction + role.reaction_bonus @@ -116,11 +116,12 @@ export default class VermineCreatureData extends foundry.abstract.TypeDataModel sizeVigor += cfg.vigor || 0 } - // Attaque : patron + taille + meute + réaction du rôle + // Attaque : patron + taille + meute. + // NB : le Rôle ne donne que la Réaction (dés + réussites automatiques), + // il n'ajoute pas de dés d'attaque (regles_creatures.txt). this.computed.attack = (patternConfig.attack || 0) + sizeAttack + (packConfig.attack || 0) - + (roleConfig.reaction || 0) // Dégâts : patron + vigueur de taille + meute this.computed.damage = (patternConfig.damage || 0) diff --git a/module/models/group.mjs b/module/models/group.mjs index 61c0433..cf8f613 100644 --- a/module/models/group.mjs +++ b/module/models/group.mjs @@ -80,7 +80,15 @@ export default class VermineGroupData extends foundry.abstract.TypeDataModel { // 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 }) + 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 }), + // Limites d'apprentissage par phase + learned: new fields.SchemaField({ + phase: new fields.NumberField({ required: true, nullable: false, integer: true, initial: 0, min: 0 }), + level: new fields.BooleanField({ required: true, initial: false }), + reserve: new fields.BooleanField({ required: true, initial: false }), + ability: new fields.BooleanField({ required: true, initial: false }) + }) }), // Rencontres diff --git a/module/system/dialogs/experiencePhaseDialog.mjs b/module/system/dialogs/experiencePhaseDialog.mjs index 6499f14..ef60b48 100644 --- a/module/system/dialogs/experiencePhaseDialog.mjs +++ b/module/system/dialogs/experiencePhaseDialog.mjs @@ -41,11 +41,19 @@ export default class ExperiencePhaseDialog extends HandlebarsApplicationMixin(fo async _prepareContext() { const characters = game.actors.filter(a => a.type === "character"); const groups = game.actors.filter(a => a.type === "group"); + // Associe chaque personnage à son premier groupe (même logique que RollDialog.#findGroup) + const memberOf = new Map(); + for (const g of groups) { + for (const id of g.system?.members || []) { + if (!memberOf.has(id)) memberOf.set(id, g.id); + } + } return { characters: characters.map(a => ({ id: a.id, name: a.name, - dice: a.system?.experience?.dice || 0 + dice: a.system?.experience?.dice || 0, + groupId: memberOf.get(a.id) || "" })), groups: groups.map(a => ({ id: a.id, @@ -63,7 +71,9 @@ export default class ExperiencePhaseDialog extends HandlebarsApplicationMixin(fo async _onRender(context, options) { this.element.querySelectorAll("[data-roll]").forEach(inp => inp.addEventListener("change", () => this.#updateUI())); + this.element.querySelector("#group-select")?.addEventListener("change", () => this.#updateGroupFilter()); this.#updateUI(); + this.#updateGroupFilter(); } #updateUI() { @@ -74,6 +84,15 @@ export default class ExperiencePhaseDialog extends HandlebarsApplicationMixin(fo if (el) el.textContent = `${danger + discoveries + duration}D`; } + /** Filtre les personnages selon le groupe sélectionné (vide = tous). */ + #updateGroupFilter() { + const groupId = this.element.querySelector("#group-select")?.value || ""; + for (const row of this.element.querySelectorAll("[data-character-id]")) { + const show = !groupId || row.dataset.groupId === groupId; + row.hidden = !show; + } + } + static async #onCancel(event, target) { this.close(); } @@ -88,12 +107,22 @@ export default class ExperiencePhaseDialog extends HandlebarsApplicationMixin(fo 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 phase = (group.system?.experience?.phase || 0) + 1; + await group.update({ + "system.experience.dice": current + collective, + "system.experience.phase": phase, + "system.experience.learned.phase": phase, + "system.experience.learned.level": false, + "system.experience.learned.reserve": false, + "system.experience.learned.ability": false + }); } } const results = []; for (const row of this.element.querySelectorAll("[data-character-id]")) { + // Quand un groupe précis est choisi, ne récompenser que ses membres. + if (groupId && row.dataset.groupId !== groupId) continue; const id = row.dataset.characterId; const actor = game.actors.get(id); if (!actor) continue; @@ -125,7 +154,10 @@ export default class ExperiencePhaseDialog extends HandlebarsApplicationMixin(fo "system.experience.learned.specialty": false, "system.experience.learned.reserve": false, "system.experience.learned.characteristic": false, - "system.experience.learned.evolution": false + "system.experience.learned.evolution": false, + "system.experience.learned.ability": false, + "system.experience.learned.background": false, + "system.experience.learned.reputation": false }; await actor.update(update); results.push({ name: actor.name, gained }); diff --git a/module/system/dialogs/groupLearningDialog.mjs b/module/system/dialogs/groupLearningDialog.mjs new file mode 100644 index 0000000..e8f080c --- /dev/null +++ b/module/system/dialogs/groupLearningDialog.mjs @@ -0,0 +1,249 @@ +import { VermineUtils } from '../roll.mjs'; + +const { HandlebarsApplicationMixin } = foundry.applications.api; + +/** + * Dialogue d'apprentissage de Groupe (phase d'Expérience). + * Permet de dépenser des Dés d'Expérience du Groupe pour tenter d'améliorer + * le Niveau de Groupe, la Réserve de Groupe ou de développer une Capacité + * collective. La progression est appliquée automatiquement en cas de réussite. + */ +export default class GroupLearningDialog 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: 560, + }, + actions: { + roll: GroupLearningDialog.#onRoll, + cancel: GroupLearningDialog.#onCancel, + }, + }; + + static PARTS = { + main: { template: 'systems/vermine2047/templates/dialogs/group-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 GroupLearningDialog({ actor }); + } + + constructor(options = {}) { + super(options); + this.#actor = options.actor; + } + + get title() { + return game.i18n.localize('VERMINE.open_learning'); + } + + _prepareContext() { + const actor = this.#actor; + return { + actor, + system: actor.system, + dice: actor.system?.experience?.dice || 0, + abilities: actor.itemTypes.ability + .filter((i) => i.system.type !== 'totem' && !i.system.learned) + .map((a) => ({ + id: a.id, + name: a.name, + threshold: a.system?.learn?.threshold ?? 5, + level: a.system?.level?.value ?? 1, + })), + }; + } + + _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 ?? 'level'; + } + + #getAbilityId() { + return this.#el.querySelector('#learning-ability')?.value ?? ''; + } + + #getDiceSpent() { + return Math.max(1, parseInt(this.#el.querySelector('#learning-dice')?.value, 10) || 1); + } + + #compute() { + const actor = this.#actor; + const type = this.#getType(); + const key = type === 'ability' ? this.#getAbilityId() : ''; + let difficulty = 7; + let handicap = 0; + let targetLevel = null; + let targetLabel = ''; + + if (type === 'level') { + const current = actor.system?.level?.value || 1; + difficulty = actor.system?.members?.length || 0; + handicap = current; + targetLevel = current + 1; + } else if (type === 'reserve') { + const current = actor.system?.reserve?.value || 0; + difficulty = current; + handicap = 2; + targetLevel = current + 1; + } else { + const ability = key ? actor.items.get(key) : null; + if (ability) { + difficulty = ability.system?.learn?.threshold ?? 5; + handicap = ability.system?.level?.value ?? 1; + targetLabel = ability.name; + } + } + + const labels = { + level: 'VERMINE.learn_level', + reserve: 'VERMINE.learn_group_reserve', + ability: 'VERMINE.learn_ability', + }; + return { + type, + key, + targetLevel, + difficulty, + handicap, + poolModifier: 0, + label: game.i18n.localize(labels[type] || labels.level), + 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-level-section', type === 'level'); + show('#learning-reserve-section', type === 'reserve'); + show('#learning-ability-section', type === 'ability'); + + 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 #onCancel() { + this.close(); + } + + /** Renvoie la clé i18n d'erreur si le jet est impossible, sinon null. */ + #getError(c) { + const actor = this.#actor; + const ex = actor.system?.experience; + // Limite : un seul Niveau, une seule Réserve et une seule Capacité par phase. + if (ex?.learned?.phase === ex?.phase && ex.learned[c.type]) { + return 'VERMINE.error_learning_limit'; + } + if (c.type === 'level' && c.targetLevel !== null && (actor.system?.level?.value || 0) >= 10) { + return 'VERMINE.error_group_level_max'; + } + if (c.type === 'reserve' && c.targetLevel !== null && + (actor.system?.reserve?.value || 0) >= (actor.system?.reserve?.max || 10)) { + return 'VERMINE.error_group_reserve_max'; + } + if (c.type === 'ability') { + const ability = c.key ? actor.items.get(c.key) : null; + const requiredLevel = ability?.system?.level?.value ?? 1; + if (ability && (actor.system?.level?.value || 1) < requiredLevel) { + return 'VERMINE.error_group_level_requirement'; + } + } + return null; + } + + static async #onRoll() { + const actor = this.#actor; + const ex = actor.system?.experience; + const spent = Math.min(this.#getDiceSpent(), ex?.dice || 0); + if (spent < 1) { + ui.notifications.warn(game.i18n.localize('VERMINE.error_no_experience_dice')); + return; + } + + const c = this.#compute(); + const errorKey = this.#getError(c); + if (errorKey) { + ui.notifications.warn(game.i18n.localize(errorKey)); + 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; + if ((roll._total ?? 0) >= required) { + await this.#applySuccess(c, spent); + } + this.close(); + } + + async #applySuccess(c, spent) { + const actor = this.#actor; + const updates = { 'system.experience.dice': (actor.system.experience.dice || 0) - spent }; + if (c.type === 'level') { + const current = actor.system.level.value || 1; + updates['system.level.value'] = Math.min(10, current + 1); + updates['system.experience.learned.level'] = true; + } else if (c.type === 'reserve') { + const current = actor.system.reserve.value || 0; + updates['system.reserve.value'] = Math.min(actor.system.reserve.max || 10, current + 1); + updates['system.experience.learned.reserve'] = true; + } else { + if (c.key) { + await actor.updateEmbeddedDocuments('Item', [{ _id: c.key, 'system.learned': true }]); + } + updates['system.experience.learned.ability'] = 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/learningDialog.mjs b/module/system/dialogs/learningDialog.mjs index 5359888..4aa1b2d 100644 --- a/module/system/dialogs/learningDialog.mjs +++ b/module/system/dialogs/learningDialog.mjs @@ -64,6 +64,29 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a 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, @@ -81,11 +104,44 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a ], specialties: actor.itemTypes?.specialty || [], skills, - specialtyCount: (actor.itemTypes?.specialty || []).length + specialtyCount: (actor.itemTypes?.specialty || []).length, + totem, + gameMode, + groupLevel, + totemAbilities, + backgrounds, + reputation: actor.system?.attributes?.reputation?.value ?? 0, + human, + adapted, + canUseAdaptedDie: VermineExperience.canUseAdaptedDie(human, adapted) }; } - async _onRender(context, options) { + #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()); @@ -104,9 +160,19 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a 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); } @@ -119,9 +185,6 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a 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; @@ -182,10 +245,71 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a 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; @@ -197,6 +321,7 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a 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: "" }; @@ -213,6 +338,10 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a 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"); @@ -229,16 +358,25 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a 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)` : ""}`); + 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(event, target) { + static async #onCancel() { this.close(); } - static async #onRoll(event, target) { + static async #onRoll() { const actor = this.#actor; const ex = actor.system?.experience; const dice = ex?.dice || 0; @@ -250,10 +388,11 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a const c = this.#compute(); - // Limite : une seule compétence, spécialité, réserve, caractéristique et - // un seul Dé d'Évolution par phase. + // 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" }; + 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; @@ -275,6 +414,61 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a 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 + ")" : ""}`; @@ -285,6 +479,7 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a 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 } } @@ -293,14 +488,16 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a const required = 1 + c.handicap; const specialtyName = this.#getSpecialtyName(); if ((roll._total ?? 0) >= required) { - await this.#applySuccess(c, spent, specialtyName); + 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, spent, specialtyName) { + async #applySuccess(c, specialtyName, successes = 0) { const actor = this.#actor; - const updates = { "system.experience.dice": (actor.system.experience.dice || 0) - spent }; + 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); @@ -316,9 +513,23 @@ export default class LearningDialog extends HandlebarsApplicationMixin(foundry.a const bonus = actor.system.experience.reserveBonus?.[c.key] || 0; updates["system.experience.reserveBonus." + c.key] = bonus + 1; updates["system.experience.learned.reserve"] = true; - } else { + } 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); diff --git a/module/system/experience.mjs b/module/system/experience.mjs index b53d9b9..9c97e09 100644 --- a/module/system/experience.mjs +++ b/module/system/experience.mjs @@ -123,6 +123,76 @@ export class VermineExperience { return 0 } + /** + * Un Dé de Totem Adapté peut remplacer un Dé d'Expérience (compte ×2) + * uniquement si le personnage possède strictement plus de Dés Adaptés + * que Humains. + * @param {number} human human totem dice + * @param {number} adapted adapted totem dice + * @returns {boolean} + */ + static canUseAdaptedDie(human, adapted) { + return adapted > human + } + + /** + * Difficulté d'un jet d'apprentissage de Capacité de Totem : valeur + * d'Apprentissage de la capacité. + * @param {object} abilityData system data de l'item ability + * @returns {number} + */ + static totemAbilityLearningDifficulty(abilityData) { + return parseInt(abilityData?.learn?.threshold, 10) || 5 + } + + /** + * Handicap d'un jet d'apprentissage de Capacité de Totem : valeur + * d'Apprentissage de la capacité. + * @param {object} abilityData system data de l'item ability + * @returns {number} + */ + static totemAbilityLearningHandicap(abilityData) { + return parseInt(abilityData?.learn?.hindrance, 10) || 0 + } + + /** + * Difficulté d'un jet d'obtention d'un Élément d'Historique. + * @returns {number} + */ + static backgroundLearningDifficulty() { + return 9 + } + + /** + * Handicap d'un jet d'obtention d'un Élément d'Historique : (I) si + * l'Historique est listé avec un astérisque. + * @param {boolean} asterisk + * @returns {number} + */ + static backgroundLearningHandicap(asterisk) { + return asterisk ? 1 : 0 + } + + /** + * Difficulté d'un jet visant à réduire le score de Réputation (devenir + * plus célèbre) : 10 moins le score que le personnage souhaite atteindre. + * @param {number} current réputation actuelle + * @returns {number} + */ + static reputationReduceDifficulty(current) { + return 10 - Math.max(0, (parseInt(current, 10) || 0) - 1) + } + + /** + * Difficulté d'un jet visant à augmenter le score de Réputation (se faire + * oublier) : égale à la Réputation actuelle. + * @param {number} current réputation actuelle + * @returns {number} + */ + static reputationIncreaseDifficulty(current) { + return parseInt(current, 10) || 0 + } + /** * Clé i18n du libellé du niveau de compétence visé. * @param {number} level diff --git a/templates/actor/appv2/character-experience.hbs b/templates/actor/appv2/character-experience.hbs index 6c6a52e..baa96d4 100644 --- a/templates/actor/appv2/character-experience.hbs +++ b/templates/actor/appv2/character-experience.hbs @@ -59,6 +59,9 @@ {{ localize 'VERMINE.learned_reserve' }} {{ localize 'VERMINE.learned_characteristic' }} {{ localize 'VERMINE.learned_evolution' }} + {{ localize 'VERMINE.learned_ability' }} + {{ localize 'VERMINE.learned_background' }} + {{ localize 'VERMINE.learned_reputation' }} diff --git a/templates/actor/appv2/character-header.hbs b/templates/actor/appv2/character-header.hbs index 8f10daf..e56c50e 100644 --- a/templates/actor/appv2/character-header.hbs +++ b/templates/actor/appv2/character-header.hbs @@ -47,14 +47,6 @@ {{else}} {{system.attributes.reputation.value}} {{/if}} - - - {{#if isEditMode}} - - {{else}} - {{system.attributes.xp.value}} - {{/if}} diff --git a/templates/actor/appv2/group-reserve.hbs b/templates/actor/appv2/group-reserve.hbs index 0a242d8..84e021a 100644 --- a/templates/actor/appv2/group-reserve.hbs +++ b/templates/actor/appv2/group-reserve.hbs @@ -33,6 +33,20 @@ {{/if}} / {{system.morale.max}}D +
+ + {{#if isEditMode}} + + {{else}} + {{system.experience.dice}} + {{/if}} + D +
+
+ + +
{{!-- Membres --}} diff --git a/templates/actor/character/character-header.hbs b/templates/actor/character/character-header.hbs index 0f8d98f..c71cc0b 100644 --- a/templates/actor/character/character-header.hbs +++ b/templates/actor/character/character-header.hbs @@ -35,10 +35,6 @@ - - - \ No newline at end of file diff --git a/templates/dialogs/experience-phase-dialog.hbs b/templates/dialogs/experience-phase-dialog.hbs index f28e7d0..b638d01 100644 --- a/templates/dialogs/experience-phase-dialog.hbs +++ b/templates/dialogs/experience-phase-dialog.hbs @@ -47,7 +47,7 @@
{{#each characters}} -
+
{{ this.name }} {{ localize 'VERMINE.experience_dice' }}: {{ this.dice }}D diff --git a/templates/dialogs/group-learning-dialog.hbs b/templates/dialogs/group-learning-dialog.hbs new file mode 100644 index 0000000..8afff06 --- /dev/null +++ b/templates/dialogs/group-learning-dialog.hbs @@ -0,0 +1,72 @@ +
+

{{ localize 'VERMINE.open_learning' }}

+ +
+ {{ localize 'VERMINE.experience_dice' }}: + {{ dice }}D +
+ +
+ + + +
+ +
+

{{ localize 'VERMINE.group_level_hint' }}

+
+ + + + + +
+ + + D +
+ +
+
+ {{ localize 'VERMINE.difficulty' }} + 5 +
+
+ {{ localize 'VERMINE.handicap' }} + 0 +
+
+ {{ localize 'VERMINE.success_required' }} + 1 +
+
+ {{ localize 'VERMINE.pool_total' }} + 1D +
+
+ +
+ + +
+
diff --git a/templates/dialogs/learning-dialog.hbs b/templates/dialogs/learning-dialog.hbs index bdb1b9b..580036f 100644 --- a/templates/dialogs/learning-dialog.hbs +++ b/templates/dialogs/learning-dialog.hbs @@ -12,6 +12,9 @@ + + +
@@ -55,6 +58,54 @@ + + + + + +