feat: gestion des Dés de Totems (influence, instincts, interdits)
Release Creation / build (release) Failing after 1m32s
Release Creation / build (release) Failing after 1m32s
- VermineTotemDice : influence Humain/Adapté (+1D/-1D par domaine, règles p. 121), gains/pertes via Instincts/Interdits avec limites (3D/totem, 5D total) et règles d'échange. - roll.mjs applique l'influence selon le Totem dominant (au lieu de l'ancienne logique basée sur identity.totem). - Dialogue TotemDiceDialog (instinct Humain/autre totem, interdit Humain, acte grave ±2D) + bouton et ligne d'influence sur la fiche personnage. - Dés de Totem gagnés en cas d'échec à l'apprentissage Dé d'Évolution (dés dépensés, garde capacité vide). - Couleur de texte @color-text-light-2 assombrie (#c9e0c0 → #3f9b55) pour la lisibilité dans tous les dialogues. fix: robustesse et visibilité des évolutions - buyEvolutionDialog : n'efface les Dés d'Évolution que si l'item est créé. - Flag mutation éditable sur la fiche item evolution + badge Adaptation/Mutation dans la liste du personnage. - loseDie : applique aussi la limite totale de 5 dés au gain Adapté. - Case "acte grave" conservée entre les rendus du dialogue.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+16
-64
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Object>}
|
||||
*/
|
||||
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<Object>}
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user