diff --git a/lang/en.json b/lang/en.json index 667bd39..2be5a9b 100644 --- a/lang/en.json +++ b/lang/en.json @@ -304,7 +304,20 @@ "die_successes": "successes", "total": "Total", "totem_dice": "Totem Dice", - "totem_hint": "Totem Hint", + "totem_hint": "Check to use totem dice (double success possible)", + "totem_neutral": "Neutral", + "totem_manage": "Manage Totem Dice (Instincts / Interdits)", + "totem_serious": "Serious act (2 dice)", + "totem_manage_hint": "Instincts: obeying the Human's Instincts gains 1 Human die; another Totem's Instincts gain 1 Adapted die. Interdit: breaking a Human Interdit loses 1 Human die (or gains 1 Adapted die if none). Max 3 dice of the same Totem and 5 dice total.", + "totem_instinct_human": "Human Instinct (+1 Human die)", + "totem_instinct_adapted": "Another Totem's Instinct (+1 Adapted die)", + "totem_interdit_human": "Human Interdit (-1 Human die)", + "totem_gained": "{totem} +1D", + "totem_gained_adapted": "+1D Adapted", + "totem_exchanged": "{totem} gained in exchange (1 opposite die removed)", + "totem_replaced": "Opposite die removed instead (max 3D reached)", + "totem_lost": "-1 Human die", + "totem_limit_reached": "Totem dice limit reached.", "FightTool": "Fight Tool", "RollTool": "Roll Tool", "Roll4Fight": "Roll for Fight", @@ -374,6 +387,27 @@ "trauma_reward": "Chosen trauma", "trauma_reward_5": "Trauma reward (+5D)", "evolution_dice": "Evolution dice", + "buy_evolution": "Buy an Adaptation/Mutation", + "evolution_name": "Name", + "evolution_level": "Level (cost in Evolution dice)", + "evolution_mutation": "Mutation", + "evolution_adaptation": "Adaptation", + "evolution_available_dice": "Accumulated Evolution dice", + "evolution_total": "Total levels (after purchase)", + "evolution_mode_max": "Game mode maximum", + "game_mode": "Game mode", + "mode_survie": "Survival", + "mode_cauchemar": "Nightmare", + "mode_apocalypse": "Apocalypse", + "evolution_buy_hint": "The purchase consumes and erases all accumulated Evolution dice. Limits: Survival 5D, Nightmare 6D, Apocalypse 7D total. Level 3/4 Mutations are forbidden in Survival; only one level 4 Mutation.", + "buy_evolution_done": "Adaptation/Mutation acquired. Evolution dice erased.", + "error_evolution_no_name": "Provide a name for the Adaptation/Mutation.", + "error_evolution_invalid_level": "Invalid level (1 to 4).", + "error_evolution_not_enough_dice": "Not enough Evolution dice for this level.", + "error_evolution_mode_limit": "Game mode maximum Evolution dice reached.", + "error_evolution_mutation_mode": "Level 3/4 Mutations require Nightmare or Apocalypse modes.", + "error_evolution_mutation_4d": "The character already has a level 4 Mutation.", + "error_evolution_create": "Could not create the Adaptation/Mutation.", "open_experience_phase": "Experience phase", "open_learning": "Learning", "validate_phase": "Validate phase", diff --git a/lang/fr.json b/lang/fr.json index daebb90..72de5b0 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -302,6 +302,19 @@ "totem_dice": "Dés de totem", "keep_totem": "Garder le totem", "totem_hint": "Cochez pour utiliser les dés de totem (double réussite possible)", + "totem_neutral": "Neutre", + "totem_manage": "Gérer les Dés de Totems (Instincts / Interdits)", + "totem_serious": "Acte grave (2 dés)", + "totem_manage_hint": "Instincts : obéir aux Instincts de l'Humain gagne 1D Humain ; ceux d'un autre Totem gagnent 1D Adapté. Interdit : briser un Interdit de l'Humain fait perdre 1D Humain (ou gagner 1D Adapté si aucun). Max 3 Dés du même Totem et 5 Dés au total.", + "totem_instinct_human": "Instinct de l'Humain (+1D Humain)", + "totem_instinct_adapted": "Instinct d'un autre Totem (+1D Adapté)", + "totem_interdit_human": "Interdit de l'Humain (-1D Humain)", + "totem_gained": "{totem} +1D", + "totem_gained_adapted": "+1D Adapté", + "totem_exchanged": "{totem} gagné en échange (1D opposé retiré)", + "totem_replaced": "Dé opposé retiré à la place (max 3D atteint)", + "totem_lost": "-1D Humain", + "totem_limit_reached": "Limite de Dés de Totems atteinte.", "attack": "Attaque", "attack_contact": "Attaque au contact", "attack_ranged": "Attaque à distance", @@ -357,6 +370,27 @@ "trauma_reward": "Traumatisme choisi", "trauma_reward_5": "Récompense de Traumatisme (+5D)", "evolution_dice": "Dés d'Évolution", + "buy_evolution": "Acheter une Adaptation/Mutation", + "evolution_name": "Nom", + "evolution_level": "Niveau (coût en Dés d'Évolution)", + "evolution_mutation": "Mutation", + "evolution_adaptation": "Adaptation", + "evolution_available_dice": "Dés d'Évolution accumulés", + "evolution_total": "Total des niveaux (après achat)", + "evolution_mode_max": "Maximum du Mode de jeu", + "game_mode": "Mode de jeu", + "mode_survie": "Survie", + "mode_cauchemar": "Cauchemar", + "mode_apocalypse": "Apocalypse", + "evolution_buy_hint": "L'achat consomme et efface tous les Dés d'Évolution accumulés. Limites : Survie 5D, Cauchemar 6D, Apocalypse 7D au total. Mutations 3D/4D interdites en Mode Survie ; une seule Mutation à 4D.", + "buy_evolution_done": "Adaptation/Mutation acquise. Dés d'Évolution effacés.", + "error_evolution_no_name": "Indiquez un nom pour l'Adaptation/Mutation.", + "error_evolution_invalid_level": "Niveau invalide (1 à 4).", + "error_evolution_not_enough_dice": "Dés d'Évolution insuffisants pour ce niveau.", + "error_evolution_mode_limit": "Maximum de Dés d'Évolution du Mode de jeu atteint.", + "error_evolution_mutation_mode": "Les Mutations de Niveau 3/4 nécessitent les Modes Cauchemar ou Apocalypse.", + "error_evolution_mutation_4d": "Le personnage possède déjà une Mutation à 4D.", + "error_evolution_create": "Impossible de créer l'Adaptation/Mutation.", "open_experience_phase": "Phase d'Expérience", "open_learning": "Apprentissage", "validate_phase": "Valider la phase", diff --git a/less/actor/actor.less b/less/actor/actor.less index d538052..7118f4e 100644 --- a/less/actor/actor.less +++ b/less/actor/actor.less @@ -696,6 +696,27 @@ &:last-child { border-bottom: none; } } + + .evolution-badge { + margin-left: 6px; + padding: 1px 6px; + font-family: "Roboto", sans-serif; + font-size: @font-size-11; + text-transform: uppercase; + border-radius: 3px; + + &.adaptation { + color: @color-text-light-0; + background: fade(@color-acid-green, 25%); + border: 1px solid @color-acid-green; + } + + &.mutation { + color: @color-text-light-0; + background: fade(@color-hemolymph, 30%); + border: 1px solid @color-hemolymph; + } + } } // ── V2 tab navigation — chitin-plate style ───────────────────────── @@ -706,7 +727,7 @@ margin: 0; padding: 0; background: @color-chitin-dark; - border-bottom: 2px solid @color-amber; + border-bottom: 2px solid @color-hemolymph; min-height: 36px; > a { @@ -730,7 +751,7 @@ } &.active { - background: linear-gradient(180deg, @color-amber 0%, @color-honeycomb 100%); + background: @color-honeycomb; color: @color-chitin-dark; &::after { diff --git a/less/actor/community.less b/less/actor/community.less index f176676..7779be1 100644 --- a/less/actor/community.less +++ b/less/actor/community.less @@ -69,7 +69,7 @@ font-family: "DistressBlack", sans-serif; text-transform: uppercase; font-size: @font-size-11; - color: @color-amber; + color: @color-text-light-2; } } @@ -111,7 +111,7 @@ font-family: "DistressBlack", sans-serif; text-transform: uppercase; font-size: @font-size-11; - color: @color-amber; + color: @color-text-light-2; } input[type="number"] { @@ -134,7 +134,7 @@ } &.crisis { border-left-color: @color-hemolymph; .gauge-state { color: @color-hemolymph; } } - &.low { border-left-color: @color-amber; .gauge-state { color: @color-amber; } } + &.low { border-left-color: @color-honeycomb; .gauge-state { color: @color-honeycomb; } } &.stable { border-left-color: @color-acid-green; .gauge-state { color: @color-acid-green; } } &.opulent { border-left-color: @theme-color-accent; .gauge-state { color: @theme-color-accent; } } } @@ -146,7 +146,7 @@ &.high { color: @color-acid-green; } &.normal { color: @color-text-light-0; } - &.low { color: @color-amber; } + &.low { color: @color-honeycomb; } &.crisis { color: @color-hemolymph; } } } diff --git a/less/actor/creature.less b/less/actor/creature.less index 5b9020a..e2a94aa 100644 --- a/less/actor/creature.less +++ b/less/actor/creature.less @@ -188,7 +188,7 @@ .capacity-type { flex: 0 0 auto; font-size: @font-size-12; - color: @color-amber; + color: @color-text-light-2; text-transform: uppercase; } diff --git a/less/actor/totem.less b/less/actor/totem.less index 1e87229..9a332ce 100644 --- a/less/actor/totem.less +++ b/less/actor/totem.less @@ -124,5 +124,37 @@ } } } + + .totem-influence-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + margin-top: 2px; + font-size: 0.75rem; + + .totem-influence-label { + color: @color-text-light-0; + + strong.totem-human { color: @totem-human-color; } + strong.totem-adapted { color: @totem-adapted-color; } + } + + .totem-manage-btn { + flex: none; + padding: 2px 8px; + cursor: pointer; + color: @color-text-light-0; + background: fade(@color-hemolymph, 20%); + border: 1px solid @color-hemolymph; + border-radius: 3px; + .transition(); + + &:hover { + color: @theme-color-dark; + background: @color-hemolymph; + } + } + } } } diff --git a/less/actor/vehicle.less b/less/actor/vehicle.less index f4310b8..92f6ee1 100644 --- a/less/actor/vehicle.less +++ b/less/actor/vehicle.less @@ -124,7 +124,7 @@ font-family: "DistressBlack", sans-serif; text-transform: uppercase; font-size: @font-size-11; - color: @color-amber; + color: @color-text-light-2; } } @@ -171,7 +171,7 @@ font-family: "DistressBlack", sans-serif; text-transform: uppercase; font-size: @font-size-11; - color: @color-amber; + color: @color-text-light-2; } } } diff --git a/less/base.less b/less/base.less index bee9a8b..d3e77ef 100644 --- a/less/base.less +++ b/less/base.less @@ -732,7 +732,7 @@ ol.chat-log { } #required { - color: @color-amber; + color: @color-text-light-2; } } @@ -894,7 +894,7 @@ ol.chat-log { .exchange-passive { font-family: "Roboto", sans-serif; font-size: @font-size-11; - color: @color-amber; + color: @color-text-light-2; opacity: 0.85; font-style: italic; } @@ -923,8 +923,8 @@ ol.chat-log { div.exchange-resolution { margin: 4px 0 6px; padding: 6px 8px; - border: 1px solid @color-amber; - background: fade(@color-amber, 8%); + border: 1px solid @color-hemolymph; + background: fade(@color-hemolymph, 8%); .resolution-header { display: flex; @@ -937,7 +937,7 @@ ol.chat-log { font-weight: 900; color: @theme-color-dark; - i { color: @color-amber; } + i { color: @color-hemolymph; } } .resolution-line { @@ -1063,7 +1063,7 @@ ol.chat-log { font-family: "DistressBlack", sans-serif; text-transform: uppercase; font-size: @font-size-11; - color: @color-amber; + color: @color-text-light-2; } .exp-value { @@ -1111,7 +1111,7 @@ ol.chat-log { margin: 0 0 6px; .exp-total { - color: @color-amber; + color: @color-text-light-2; } } @@ -1126,7 +1126,7 @@ ol.chat-log { font-family: "DistressBlack", sans-serif; text-transform: uppercase; font-size: @font-size-11; - color: @color-amber; + color: @color-text-light-2; } } diff --git a/less/dialogs.less b/less/dialogs.less index cf79c7d..45c63d4 100644 --- a/less/dialogs.less +++ b/less/dialogs.less @@ -188,7 +188,7 @@ iframe { > label { font-size: @font-size-13; - color: @color-amber; + color: @color-text-light-2; white-space: nowrap; } @@ -294,7 +294,7 @@ iframe { cursor: pointer; &:hover { - color: @color-amber; + color: @color-text-light-highlight; } } @@ -347,7 +347,7 @@ iframe { cursor: pointer; &:hover { - color: @color-amber; + color: @color-text-light-highlight; } } } @@ -381,7 +381,7 @@ iframe { cursor: pointer; &:hover { - color: @color-amber; + color: @color-text-light-highlight; } small { @@ -487,7 +487,7 @@ iframe { .label { font-size: @font-size-11; - color: @color-amber; + color: @color-text-light-2; } .combat-target-tag { @@ -527,7 +527,7 @@ iframe { .label { font-size: @font-size-12; - color: @color-amber; + color: @color-text-light-2; } .exp-value { @@ -555,7 +555,7 @@ iframe { input[type="radio"] { flex: none; } - &:hover { color: @color-amber; } + &:hover { color: @color-text-light-highlight; } } } @@ -581,8 +581,8 @@ iframe { flex-wrap: wrap; gap: 8px 16px; padding: 8px; - border: 1px solid @color-amber; - background: fade(@color-amber, 8%); + border: 1px solid @color-hemolymph; + background: fade(@color-hemolymph, 8%); .context-item { display: flex; @@ -593,7 +593,7 @@ iframe { font-family: "DistressBlack", sans-serif; text-transform: uppercase; font-size: @font-size-11; - color: @color-amber; + color: @color-text-light-2; } .context-value { @@ -721,3 +721,92 @@ iframe { } } } + +// ── Achat d'Adaptation/Mutation ─────────────────────────────────────── +.application.buy-evolution .bonus-item { + align-items: center; + gap: 8px; + + > label.label { + flex: none; + font-size: @font-size-12; + color: @color-text-light-2; + white-space: nowrap; + } + + input[type="text"] { + flex: 1 1 auto; + min-width: 0; + } + + select { + flex: 1 1 auto; + min-width: 0; + width: auto; + } + + input[type="checkbox"] { + flex: none; + width: 18px; + height: 18px; + } +} + +// ── Gestion des Dés de Totems ───────────────────────────────────────── +.application.totem-dice .totem-dice-state { + gap: 12px; + padding: 6px 8px; + border: 1px solid @color-hemolymph; + background: fade(@color-hemolymph, 8%); + + .totem-state { + display: flex; + align-items: center; + gap: 6px; + + .label { + font-family: "DistressBlack", sans-serif; + text-transform: uppercase; + font-size: @font-size-12; + color: @color-text-light-2; + white-space: nowrap; + } + + &.totem-human .label { color: @color-text-light-2; } + &.totem-adapted .label { color: @color-text-light-2; } + + .value { + font-family: "Roboto", sans-serif; + font-size: @font-size-14; + font-weight: 700; + color: @color-text-light-0; + } + } +} + +.application.totem-dice .totem-influence { + font-size: @font-size-16; +} + +.application.totem-dice .totem-actions { + gap: 8px; + + button { + flex: 1 1 100%; + font-family: "DistressBlack", sans-serif; + text-transform: uppercase; + font-size: @font-size-12; + padding: 6px 10px; + cursor: pointer; + color: @color-text-light-0; + background: fade(@color-hemolymph, 15%); + border: 1px solid @color-hemolymph; + border-radius: 3px; + .transition(); + + &:hover { + color: @theme-color-dark; + background: @color-hemolymph; + } + } +} diff --git a/less/variables.less b/less/variables.less index b7f475d..6967108 100644 --- a/less/variables.less +++ b/less/variables.less @@ -20,7 +20,7 @@ // Niveaux de gris pour le texte clair @color-text-light-0: #fff; @color-text-light-1: #e0f0f0; -@color-text-light-2: #c9e0c0; +@color-text-light-2: #3f9b55; @color-text-light-3: #90c4a4; @color-text-light-4: #80c08b; @color-text-light-5: #60b06b; @@ -139,7 +139,6 @@ // Couleurs insecte / organique @color-acid-green: #1e7d32; -@color-amber: #e8b84b; @color-chitin-dark: #2a2520; @color-hemolymph: #8b4513; @color-membrane: rgba(106, 176, 76, 0.1); diff --git a/module/applications/sheets/character-sheet.mjs b/module/applications/sheets/character-sheet.mjs index 2a7fda3..5f26083 100644 --- a/module/applications/sheets/character-sheet.mjs +++ b/module/applications/sheets/character-sheet.mjs @@ -10,7 +10,9 @@ export default class VermineCharacterSheetV2 extends VermineBaseActorSheet { addSpecialty: VermineCharacterSheetV2.#onAddSpecialty, openExperiencePhase: VermineCharacterSheetV2.#onOpenExperiencePhase, openLearning: VermineCharacterSheetV2.#onOpenLearning, - validatePhase: VermineCharacterSheetV2.#onValidatePhase + validatePhase: VermineCharacterSheetV2.#onValidatePhase, + buyEvolution: VermineCharacterSheetV2.#onBuyEvolution, + openTotemDice: VermineCharacterSheetV2.#onOpenTotemDice } } @@ -133,4 +135,18 @@ export default class VermineCharacterSheetV2 extends VermineBaseActorSheet { await VermineExperience.resetPhaseLimits(this.document) ui.notifications.info(game.i18n.localize("VERMINE.validate_phase_done")) } + + /** Ouvre le dialogue d'achat d'une Adaptation/Mutation (Dés d'Évolution). */ + static async #onBuyEvolution(event, target) { + const { default: BuyEvolutionDialog } = await import("../../system/dialogs/buyEvolutionDialog.mjs") + const dialog = await BuyEvolutionDialog.create({ actorId: this.document.id }) + if (dialog) dialog.render(true) + } + + /** Ouvre le dialogue de gestion des Dés de Totems. */ + static async #onOpenTotemDice(event, target) { + const { default: TotemDiceDialog } = await import("../../system/dialogs/totemDiceDialog.mjs") + const dialog = await TotemDiceDialog.create({ actorId: this.document.id }) + if (dialog) dialog.render(true) + } } diff --git a/module/models/evolution.mjs b/module/models/evolution.mjs index da03058..2cc29cd 100644 --- a/module/models/evolution.mjs +++ b/module/models/evolution.mjs @@ -13,7 +13,8 @@ export default class VermineEvolutionData extends foundry.abstract.TypeDataModel const fields = foundry.data.fields return { ...listItemSchema(), - level: levelSchema(1, 1, 4) + level: levelSchema(1, 1, 4), + mutation: new fields.BooleanField({ required: true, initial: false }) } } } diff --git a/module/system/config.mjs b/module/system/config.mjs index fd9c59c..9463227 100644 --- a/module/system/config.mjs +++ b/module/system/config.mjs @@ -288,6 +288,23 @@ VERMINE.totems = { "loner": "TOTEMS.loner.name", "adapted": "TOTEMS.adapted.name" } +/** + * Influence des Dés de Totems (règles p. 121). + * Selon le Totem dominant (strictement plus de Dés Humains ou Adaptés), + * le personnage subit un Bonus/Malus de 1D sur les Domaines de Compétences. + * Neutre : aucun modificateur. + */ +VERMINE.totemInfluence = { + "human": { + "bonus": ["man", "tool", "world"], + "malus": ["animal", "survival", "weapon"] + }, + "adapted": { + "bonus": ["animal", "survival", "weapon"], + "malus": ["man", "tool", "world"] + } +} + VERMINE.totem_opposites = { "human": "TOTEMS.adapted.name", "predator": "TOTEMS.scavenger.name", diff --git a/module/system/dialogs/buyEvolutionDialog.mjs b/module/system/dialogs/buyEvolutionDialog.mjs new file mode 100644 index 0000000..3b8c9c5 --- /dev/null +++ b/module/system/dialogs/buyEvolutionDialog.mjs @@ -0,0 +1,115 @@ +import { VermineExperience } from "../experience.mjs"; + +const { HandlebarsApplicationMixin } = foundry.applications.api; + +/** + * Dialogue d'achat d'une Adaptation/Mutation avec les Dés d'Évolution + * accumulés. Vérifie le coût (niveau 1-4), la limite du Mode de jeu et les + * restrictions sur les Mutations ; en cas de succès, crée l'item et efface + * les Dés d'Évolution accumulés. + */ +export default class BuyEvolutionDialog extends HandlebarsApplicationMixin(foundry.applications.api.ApplicationV2) { + + #actor; + + static DEFAULT_OPTIONS = { + classes: ["vermine-roll", "buy-evolution"], + tag: "form", + window: { + icon: "fas fa-cart-shopping", + resizable: false + }, + position: { + width: 480, + height: 460 + }, + actions: { + buy: BuyEvolutionDialog.#onBuy, + cancel: BuyEvolutionDialog.#onCancel + } + }; + + static PARTS = { + main: { template: "systems/vermine2047/templates/dialogs/buy-evolution-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 BuyEvolutionDialog({ actor }); + } + + constructor(options = {}) { + super(options); + this.#actor = options.actor; + } + + get title() { + return game.i18n.localize("VERMINE.buy_evolution"); + } + + async _prepareContext() { + const actor = this.#actor; + const mode = VermineExperience.gameMode(); + const modeLabels = { + 1: game.i18n.localize("VERMINE.mode_survie"), + 2: game.i18n.localize("VERMINE.mode_cauchemar"), + 3: game.i18n.localize("VERMINE.mode_apocalypse") + }; + const levels = []; + for (let value = 1; value <= 4; value++) { + levels.push({ value, cost: value }); + } + return { + evolutionDice: actor.system?.experience?.evolution || 0, + defaultName: game.i18n.localize("ITEMS.new_evolution"), + levels, + totalLevels: VermineExperience.evolutionTotalLevels(actor), + modeMax: VermineExperience.evolutionMaxForMode(mode), + modeLabel: modeLabels[mode] || "" + }; + } + + static async #onCancel(event, target) { + this.close(); + } + + static async #onBuy(event, target) { + const actor = this.#actor; + const name = this.element.querySelector("#evolution-name")?.value?.trim() || ""; + const cost = parseInt(this.element.querySelector("#evolution-level")?.value, 10) || 0; + const mutation = this.element.querySelector("#evolution-mutation")?.checked || false; + + if (!name) { + ui.notifications.warn(game.i18n.localize("VERMINE.error_evolution_no_name")); + return; + } + + const error = VermineExperience.validateEvolutionPurchase(actor, cost, mutation); + if (error) { + ui.notifications.warn(game.i18n.localize(error)); + return; + } + + const created = await actor.createEmbeddedDocuments("Item", [{ + name, + type: "evolution", + system: { + level: { value: cost }, + mutation + } + }]); + // N'efface les Dés d'Évolution que si l'item a bien été créé. + if (!created || created.length === 0) { + ui.notifications.warn(game.i18n.localize("VERMINE.error_evolution_create")); + return; + } + await actor.update({ "system.experience.evolution": 0 }); + + ui.notifications.info(game.i18n.localize("VERMINE.buy_evolution_done")); + this.close(); + } +} diff --git a/module/system/dialogs/totemDiceDialog.mjs b/module/system/dialogs/totemDiceDialog.mjs new file mode 100644 index 0000000..84e5674 --- /dev/null +++ b/module/system/dialogs/totemDiceDialog.mjs @@ -0,0 +1,125 @@ +import { VermineTotemDice } from "../totem-dice.mjs"; + +const { HandlebarsApplicationMixin } = foundry.applications.api; + +/** + * Dialogue de gestion des Dés de Totems (Humain / Adapté). + * Permet d'appliquer les gains et pertes liés aux Instincts et Interdits + * (1D ou 2D selon la gravité de l'acte), en respectant les limites + * (3 Dés du même Totem, 5 Dés au maximum) et les règles d'échange. + */ +export default class TotemDiceDialog extends HandlebarsApplicationMixin(foundry.applications.api.ApplicationV2) { + + #actor; + #serious = false; + + static DEFAULT_OPTIONS = { + classes: ["vermine-roll", "totem-dice"], + tag: "form", + window: { + icon: "fas fa-dice-d10", + resizable: false + }, + position: { + width: 480, + height: 420 + }, + actions: { + gainHuman: TotemDiceDialog.#onGainHuman, + gainAdapted: TotemDiceDialog.#onGainAdapted, + loseHuman: TotemDiceDialog.#onLoseHuman, + close: TotemDiceDialog.#onClose + } + }; + + static PARTS = { + main: { template: "systems/vermine2047/templates/dialogs/totem-dice-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 TotemDiceDialog({ actor }); + } + + constructor(options = {}) { + super(options); + this.#actor = options.actor; + } + + get title() { + return game.i18n.localize("VERMINE.totem_dice"); + } + + async _prepareContext() { + const actor = this.#actor; + const influence = VermineTotemDice.getInfluence(actor); + const influenceLabels = { + human: game.i18n.localize("TOTEMS.human.name"), + adapted: game.i18n.localize("TOTEMS.adapted.name"), + neutral: game.i18n.localize("VERMINE.totem_neutral") + }; + return { + humanValue: actor.system?.adaptation?.totems?.human?.value ?? 0, + humanMax: actor.system?.adaptation?.totems?.human?.max ?? 3, + adaptedValue: actor.system?.adaptation?.totems?.adapted?.value ?? 0, + adaptedMax: actor.system?.adaptation?.totems?.adapted?.max ?? 3, + influenceLabel: influenceLabels[influence] || "", + serious: this.#serious + }; + } + + _onRender(context, options) { + super._onRender(context, options); + const checkbox = this.element.querySelector("#totem-serious"); + if (checkbox) { + checkbox.checked = this.#serious; + checkbox.addEventListener("change", () => { this.#serious = checkbox.checked; }); + } + } + + #getCount() { + return this.element.querySelector("#totem-serious")?.checked ? 2 : 1; + } + + #notify(result) { + const key = result.totem === "human" ? "TOTEMS.human.name" : "TOTEMS.adapted.name"; + const totemName = game.i18n.localize(key); + const parts = []; + if (result.gained) parts.push(game.i18n.localize("VERMINE.totem_gained").replace("{totem}", totemName)); + if (result.exchanged) parts.push(game.i18n.localize("VERMINE.totem_exchanged").replace("{totem}", totemName)); + if (result.replaced) parts.push(game.i18n.localize("VERMINE.totem_replaced").replace("{totem}", totemName)); + if (result.lost) parts.push(game.i18n.localize("VERMINE.totem_lost")); + if (result.gainedAdapted) parts.push(game.i18n.localize("VERMINE.totem_gained_adapted")); + if (result.refused) parts.push(game.i18n.localize("VERMINE.totem_limit_reached")); + const text = parts.join(" "); + if (result.refused && !result.gained && !result.lost && !result.exchanged && !result.replaced) { + ui.notifications.warn(text || game.i18n.localize("VERMINE.totem_limit_reached")); + } else if (text) { + ui.notifications.info(text); + } + this.render(); + } + + static async #onGainHuman() { + const result = await VermineTotemDice.gainDie(this.#actor, "human", this.#getCount()); + this.#notify(result); + } + + static async #onGainAdapted() { + const result = await VermineTotemDice.gainDie(this.#actor, "adapted", this.#getCount()); + this.#notify(result); + } + + static async #onLoseHuman() { + const result = await VermineTotemDice.loseDie(this.#actor, "human", this.#getCount()); + this.#notify(result); + } + + static #onClose() { + this.close(); + } +} diff --git a/module/system/experience.mjs b/module/system/experience.mjs index 59cb579..ab72646 100644 --- a/module/system/experience.mjs +++ b/module/system/experience.mjs @@ -233,4 +233,62 @@ export class VermineExperience { } if (Object.keys(update).length) await actor.update(update) } + + /** + * Mode de jeu actif (1=Survie, 2=Cauchemar, 3=Apocalypse). + * @returns {number} + */ + static gameMode() { + return parseInt(game.settings.get("vermine2047", "game-mode"), 10) || 1 + } + + /** + * Total de Dés d'Évolution maximum selon le Mode de jeu. + * @param {number} mode 1=Survie, 2=Cauchemar, 3=Apocalypse + * @returns {number} + */ + static evolutionMaxForMode(mode) { + switch (parseInt(mode, 10)) { + case 2: return 6 + case 3: return 7 + default: return 5 + } + } + + /** + * Somme des niveaux des Adaptations/Mutations déjà possédées. + * @param {foundry.abstract.Document} actor personnage + * @returns {number} + */ + static evolutionTotalLevels(actor) { + return (actor?.itemTypes?.evolution || []).reduce((sum, item) => sum + (item.system?.level?.value || 0), 0) + } + + /** + * Valide l'achat d'une Adaptation/Mutation contre les Dés d'Évolution + * accumulés. Retourne une clé i18n d'erreur, ou null si l'achat est permis. + * Règles p. 116-117 : coût = niveau ; total limité par le Mode ; Mutations + * 3D/4D interdites en Mode Survie ; une seule Mutation à 4D. + * @param {foundry.abstract.Document} actor personnage + * @param {number} cost niveau de l'Adaptation/Mutation (1-4) + * @param {boolean} mutation vrai si c'est une Mutation + * @returns {string|null} + */ + static validateEvolutionPurchase(actor, cost, mutation) { + const level = parseInt(cost, 10) || 0 + const accumulated = actor.system?.experience?.evolution || 0 + const mode = this.gameMode() + if (accumulated < level) return "VERMINE.error_evolution_not_enough_dice" + if (level < 1 || level > 4) return "VERMINE.error_evolution_invalid_level" + const total = this.evolutionTotalLevels(actor) + level + if (total > this.evolutionMaxForMode(mode)) return "VERMINE.error_evolution_mode_limit" + if (mutation && level >= 3 && mode < 2) return "VERMINE.error_evolution_mutation_mode" + if (mutation && level === 4) { + const hasFourDMutation = (actor?.itemTypes?.evolution || []).some( + (item) => item.system?.mutation && (item.system?.level?.value || 0) === 4 + ) + if (hasFourDMutation) return "VERMINE.error_evolution_mutation_4d" + } + return null + } } diff --git a/module/system/roll.mjs b/module/system/roll.mjs index 95a51b8..df49157 100644 --- a/module/system/roll.mjs +++ b/module/system/roll.mjs @@ -1,3 +1,5 @@ +import { VermineTotemDice } from "./totem-dice.mjs"; + export class VermineUtils { /** * Rolls dice with Vermine2047-specific rules. @@ -49,12 +51,12 @@ export class VermineUtils { // Declare variables let formula = ""; let modFormula = null; - let totemBonus = { human: 0, adapted: 0 }; - // Calculate domain bonuses for totems - if (skillCategory) { - totemBonus = this._calculateTotemDomainBonuses(skillCategory, actor); - } + // Influence des Dés de Totems : Bonus/Malus de 1D selon le Totem dominant + // et le Domaine de la Compétence (règles p. 121). S'applique même sans + // utiliser un Dé de Totem. + const influenceMod = this._calculateTotemInfluence(skillCategory, actor); + NoD += influenceMod; // Apply automatic successes from skill mastery let adjustedDifficulty = difficulty; @@ -65,11 +67,6 @@ export class VermineUtils { const humanDifficulty = adjustedDifficulty; const humanFormula = `(1D10cs>=${humanDifficulty}[human_${safeUserName}]*2)`; - // Apply domain bonus/malus - if (totemBonus.human !== 0) { - NoD += totemBonus.human; - } - modFormula = humanFormula; } @@ -79,11 +76,6 @@ export class VermineUtils { const adaptedDifficulty = adjustedDifficulty; const adaptedFormula = `(1D10cs>=${adaptedDifficulty}[adapted_${safeUserName}]*2)`; - // Apply domain bonus/malus - if (totemBonus.adapted !== 0) { - NoD += totemBonus.adapted; - } - // Build combined formula if (modFormula !== null) { modFormula = `${modFormula}+${adaptedFormula}`; @@ -113,8 +105,8 @@ export class VermineUtils { formula = modFormula !== null ? `${baseFormula}+${modFormula}` : baseFormula; // Reconcile the breakdown displayed in chat with the dice actually rolled. - // Domain bonuses/malus are only applied when the corresponding totem die - // is used, and totem dice replace (not add to) base-pool dice. + // L'influence s'applique au pool ; les Dés de Totem remplacent (n'ajoutent + // pas) des dés du pool de base. if (poolBreakdown) { // Count totem dice present in the final modifier formula. let totemDice = 0; @@ -122,14 +114,9 @@ export class VermineUtils { if (modFormula.includes("human_")) totemDice += 1; if (modFormula.includes("adapted_")) totemDice += 1; } - // Only count the domain bonus of totems actually activated in the roll. - const domainBonus = (totems.human ? totemBonus.human : 0) - + (totems.adapted ? totemBonus.adapted : 0); - const basePool = poolBreakdown.total; poolBreakdown.totemDice = totemDice; - poolBreakdown.domainBonus = domainBonus; + poolBreakdown.domainBonus = influenceMod; // Total = nombre réel de dés lancés (NoD réguliers + dés de totem). - // Équivaut à basePool + domainBonus sauf si le pool régulier clampe à 0. poolBreakdown.total = NoD + totemDice; } @@ -145,7 +132,7 @@ export class VermineUtils { skillCategory: skillCategory, skillLevel: skillLevel, hasSpecialty: hasSpecialty, - totemBonuses: { ...totemBonus }, + totemInfluence: influenceMod, baseNoD: NoD, rerolls: Reroll, selfControl: self_control, @@ -188,47 +175,12 @@ export class VermineUtils { * Calculates domain bonuses/penalties for totems. * @param {string} skillCategory - The skill category * @param {Actor} actor - The actor - * @returns {Object} Bonuses for each totem {human: number, adapted: number} + * @returns {number} Modificateur d'influence (-1, 0 ou +1) */ - static _calculateTotemDomainBonuses(skillCategory, actor) { - const bonuses = { human: 0, adapted: 0 }; - - // Validate inputs - if (!CONFIG.VERMINE?.totemDomains || !actor?.system?.identity?.totem) { - return bonuses; - } - - const actorTotem = actor.system.identity.totem; - - // Check if actor's totem exists in configuration - if (!CONFIG.VERMINE.totemDomains[actorTotem]) { - return bonuses; - } - - const totemConfig = CONFIG.VERMINE.totemDomains[actorTotem]; - - if (!totemConfig?.domains) { - return bonuses; - } - - // Get actor's preferred skill category - const preferredCategory = actor.system.skill_categories?.preferred; - - // Bonus for actor's totem if preferred category is in its domains - if (preferredCategory && totemConfig.domains.includes(preferredCategory)) { - bonuses[actorTotem] = totemConfig.bonus || 1; - } - - // Penalty for opposite totem if preferred category is in its domains - const oppositeTotem = CONFIG.VERMINE.totem_opposites?.[actorTotem]; - if (oppositeTotem && CONFIG.VERMINE.totemDomains[oppositeTotem]) { - const oppositeConfig = CONFIG.VERMINE.totemDomains[oppositeTotem]; - if (preferredCategory && oppositeConfig?.domains?.includes(preferredCategory)) { - bonuses[oppositeTotem] = -(oppositeConfig.bonus || 1); - } - } - - return bonuses; + static _calculateTotemInfluence(skillCategory, actor) { + // Les Dés de Totems n'existent que sur les personnages. + if (actor?.type !== "character" || !skillCategory) return 0; + return VermineTotemDice.influenceModifier(actor, skillCategory); } /** diff --git a/module/system/totem-dice.mjs b/module/system/totem-dice.mjs new file mode 100644 index 0000000..e874c38 --- /dev/null +++ b/module/system/totem-dice.mjs @@ -0,0 +1,171 @@ +/** + * VermineTotemDice - Gestion des Dés de Totems (Humain / Adapté). + * + * Règles p. 121 : + * - Influence : si un joueur possède strictement plus de Dés d'un Totem que + * de l'autre, ce Totem prend l'ascendant (+1D/-1D sur certains Domaines). + * - Gains et pertes via les Instincts et les Interdits (1D ou 2D selon la + * gravité), avec des limites (3 Dés du même Totem, 5 Dés au maximum). + */ +export class VermineTotemDice { + + /** + * Totem dominant d'un personnage. + * @param {foundry.abstract.Document} actor + * @returns {'human'|'adapted'|'neutral'} + */ + static getInfluence(actor) { + const human = actor.system?.adaptation?.totems?.human?.value || 0; + const adapted = actor.system?.adaptation?.totems?.adapted?.value || 0; + if (human > adapted) return "human"; + if (adapted > human) return "adapted"; + return "neutral"; + } + + /** + * Modificateur (Bonus/Malus) d'influence pour une catégorie de compétence. + * @param {foundry.abstract.Document} actor + * @param {string} skillCategory catégorie (man, animal, tool, weapon, survival, world) + * @returns {number} -1, 0 ou +1 + */ + static influenceModifier(actor, skillCategory) { + const influence = this.getInfluence(actor); + if (influence === "neutral" || !skillCategory) return 0; + const cfg = CONFIG.VERMINE?.totemInfluence?.[influence]; + if (!cfg) return 0; + if ((cfg.bonus || []).includes(skillCategory)) return 1; + if ((cfg.malus || []).includes(skillCategory)) return -1; + return 0; + } + + /** + * Applique un gain d'un Dé de Totem selon les règles d'échange et de limites. + * Retourne un objet décrivant ce qui s'est passé (pour notification). + * @param {foundry.abstract.Document} actor + * @param {'human'|'adapted'} totem + * @param {number} [count=1] 1 ou 2 dés (acte grave) + * @returns {Promise} + */ + static async gainDie(actor, totem, count = 1) { + const human = actor.system.adaptation.totems.human; + const adapted = actor.system.adaptation.totems.adapted; + const actions = []; + + for (let i = 0; i < count; i++) { + const step = this._gainOneDie(human.value, adapted.value, totem); + if (step.unchanged) { + actions.push({ ok: false, reason: "max" }); + break; + } + human.value = step.human; + adapted.value = step.adapted; + actions.push({ ok: true, exchanged: step.exchanged, replaced: step.replaced }); + } + + await actor.update({ + "system.adaptation.totems.human.value": human.value, + "system.adaptation.totems.adapted.value": adapted.value + }); + + return this._summarize(actions, totem); + } + + /** + * Applique une perte d'un Dé de Totem. + * Règle : seul l'Humain a des Interdits. Briser un Interdit Humain fait + * perdre 1D Humain ; si le personnage n'en possède pas, il gagne 1D Adapté. + * @param {foundry.abstract.Document} actor + * @param {'human'} totem + * @param {number} [count=1] 1 ou 2 dés (acte grave) + * @returns {Promise} + */ + static async loseDie(actor, totem, count = 1) { + const human = actor.system.adaptation.totems.human; + const adapted = actor.system.adaptation.totems.adapted; + const actions = []; + + for (let i = 0; i < count; i++) { + if (human.value > 0) { + human.value -= 1; + actions.push({ ok: true, lost: true }); + } else if (adapted.value < adapted.max && (human.value + adapted.value) < 5) { + // Pas de Dé Humain : gagne un Dé Adapté à la place (limites appliquées). + adapted.value += 1; + actions.push({ ok: true, gainedAdapted: true }); + } else { + actions.push({ ok: false, reason: "max" }); + break; + } + } + + await actor.update({ + "system.adaptation.totems.human.value": human.value, + "system.adaptation.totems.adapted.value": adapted.value + }); + + return this._summarize(actions, totem); + } + + /** + * Ajoute un Dé du Totem donné en respectant les limites et les échanges. + * Règles : + * - 3 Dés max du même Totem, 5 Dés max au total. + * - Si le joueur gagne un Dé qu'il ne peut conserver, il peut supprimer un + * Dé du Totem opposé à la place ; sinon le gain est annulé. + * - Gagner un 4e Dé Adapté est impossible, mais supprime un Dé Humain. + * @param {number} human + * @param {number} adapted + * @param {'human'|'adapted'} totem + * @returns {Object} + */ + static _gainOneDie(human, adapted, totem) { + const limitTotem = 3; + const opposite = totem === "human" ? "adapted" : "human"; + const total = human + adapted; + const current = totem === "human" ? human : adapted; + const oppositeVal = totem === "human" ? adapted : human; + + // Le Totem cible est déjà au maximum (3) : impossible d'empiler. + if (current >= limitTotem) { + if (oppositeVal > 0) { + // Supprime un Dé du Totem opposé à la place (le gain n'augmente pas). + const result = { human, adapted, unchanged: false, replaced: true }; + result[opposite] -= 1; + return result; + } + return { human, adapted, unchanged: true }; + } + + // Limite totale atteinte (5) : peut remplacer un Dé opposé. + if (total >= 5) { + if (oppositeVal > 0) { + const result = { human, adapted, unchanged: false, exchanged: true }; + result[totem] += 1; + result[opposite] -= 1; + return result; + } + return { human, adapted, unchanged: true }; + } + + // Gain normal. + const result = { human, adapted, unchanged: false }; + result[totem] += 1; + return result; + } + + /** + * Résume les actions appliquées en un libellé localisé. + * @param {Array} actions + * @param {'human'|'adapted'} totem + * @returns {Object} + */ + static _summarize(actions, totem) { + const gained = actions.filter(a => a.ok && !a.exchanged && !a.replaced && !a.lost && !a.gainedAdapted).length; + const exchanged = actions.filter(a => a.exchanged).length; + const replaced = actions.filter(a => a.replaced).length; + const lost = actions.filter(a => a.lost).length; + const gainedAdapted = actions.filter(a => a.gainedAdapted).length; + const refused = actions.some(a => !a.ok); + return { totem, gained, exchanged, replaced, lost, gainedAdapted, refused }; + } +} diff --git a/templates/actor/appv2/character-main.hbs b/templates/actor/appv2/character-main.hbs index c4cc99f..ac7d2fd 100644 --- a/templates/actor/appv2/character-main.hbs +++ b/templates/actor/appv2/character-main.hbs @@ -74,6 +74,22 @@ + {{!-- Influence + gestion des Dés de Totems --}} +
+ + {{ localize "VERMINE.influence" }}: + {{#ifgt system.adaptation.totems.human.value system.adaptation.totems.adapted.value}} + {{ localize "TOTEMS.human.name" }} + {{else ifgt system.adaptation.totems.adapted.value system.adaptation.totems.human.value}} + {{ localize "TOTEMS.adapted.name" }} + {{else}} + {{ localize "VERMINE.totem_neutral" }} + {{/ifgt}} + + +
+