Chapitre 3

Documents et Extensions

Les Documents sont les entités fondamentales de FoundryVTT. Apprenez à les étendre pour ajouter des comportements personnalisés à vos acteurs, objets et effets.

Introduction aux Documents Foundry

Les Documents représentent les données persistantes du monde : acteurs, objets, scènes, messages de chat, etc. Chaque Document est une instance de classe JavaScript qui encapsule des données et des comportements.

Étendre les classes de Documents permet d'ajouter :

  • Des méthodes métier personnalisées - jets de dés, calculs de dégâts, repos
  • Une logique de préparation des données - modificateurs, bonus calculés
  • Des hooks de cycle de vie - actions lors de création, modification, suppression
  • Des comportements spécifiques au système - règles de jeu, automatisations
📌 Différence avec DataModel

Les DataModels définissent la structure des données (system).
Les Documents définissent le comportement et les méthodes qui manipulent ces données.

Hiérarchie des classes Document

foundry.abstract.Document
    │
    ├── Actor
    │   └── Actor5e (extension système)
    │
    ├── Item  
    │   └── Item5e
    │
    ├── ActiveEffect
    │   └── ActiveEffect5e
    │
    ├── ChatMessage
    │   └── ChatMessage5e
    │
    ├── Combat
    │   └── Combat5e
    │
    ├── Combatant
    │   └── Combatant5e
    │
    ├── JournalEntry / JournalEntryPage
    ├── Scene / Token
    ├── User
    └── ... (autres documents core)

Documents principaux à étendre

Document Usage Extension courante
Actor Personnages, PNJ, créatures Calculs de stats, jets, repos
Item Objets, sorts, capacités Utilisation, dégâts, propriétés
ActiveEffect Buffs, debuffs, conditions Transfert, expiration, suppression
ChatMessage Messages de chat Formatage, boutons d'action
Combat Rencontres Initiative, tours personnalisés
Combatant Participants au combat Statut, initiatives spéciales
TokenDocument Représentation sur canvas Bars, overlays, vision

Cycle de vie d'un Document

Chaque Document passe par plusieurs phases. Comprendre ce cycle est essentiel pour savoir où placer votre logique personnalisée.

┌─────────────────────────────────────────────────────────────────┐
│                    CRÉATION (_preCreate → _onCreate)            │
├─────────────────────────────────────────────────────────────────┤
│  _preCreate(data, options, user)                                │
│    ├── Valider/modifier les données AVANT création              │
│    ├── Peut annuler la création en retournant false             │
│    └── Appelé côté client ET serveur                            │
│                                                                 │
│  _onCreate(data, options, userId)                               │
│    ├── Réagir APRÈS création (effets secondaires)               │
│    └── Données déjà persistées en base                          │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    LECTURE (prepareData)                        │
├─────────────────────────────────────────────────────────────────┤
│  prepareData()                                                  │
│    ├── prepareBaseData()       // Données de base               │
│    ├── prepareEmbeddedDocuments() // Items, Effects             │
│    ├── applyActiveEffects()    // Appliquer les effets          │
│    └── prepareDerivedData()    // Données calculées             │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    MODIFICATION (_preUpdate → _onUpdate)        │
├─────────────────────────────────────────────────────────────────┤
│  _preUpdate(changed, options, user)                             │
│    ├── Modifier/valider les changements                         │
│    └── Peut annuler en retournant false                         │
│                                                                 │
│  _onUpdate(changed, options, userId)                            │
│    ├── Réagir aux changements                                   │
│    └── Re-render les sheets si nécessaire                       │
└─────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    SUPPRESSION (_preDelete → _onDelete)         │
├─────────────────────────────────────────────────────────────────┤
│  _preDelete(options, user)                                      │
│    └── Peut annuler en retournant false                         │
│                                                                 │
│  _onDelete(options, userId)                                     │
│    └── Nettoyage, effets secondaires                            │
└─────────────────────────────────────────────────────────────────┘
💡 Règle d'or
  • _pre* : Validation et modification des données, peut annuler l'opération
  • _on* : Effets secondaires, ne peut plus annuler

prepareData() vs prepareDerivedData()

La méthode prepareData() est appelée chaque fois qu'un Document est chargé ou modifié. Elle orchestre plusieurs sous-méthodes dans un ordre précis.

Ordre d'exécution

prepareData() {
  // 1. Réinitialiser les caches
  this._preparationWarnings = [];
  
  // 2. Préparer les données de base (AVANT les effets)
  this.prepareBaseData();
  
  // 3. Préparer les documents imbriqués (Items, Effects)
  this.prepareEmbeddedDocuments();
  
  // 4. Appliquer les ActiveEffects
  this.applyActiveEffects();
  
  // 5. Calculer les données dérivées (APRÈS les effets)
  this.prepareDerivedData();
}

Différence clé

Méthode Timing Usage
prepareBaseData() Avant les ActiveEffects Valeurs de base, niveau, bonus de maîtrise brut
prepareDerivedData() Après les ActiveEffects Modificateurs finaux, totaux calculés
// Exemple pratique
export default class ActorMonSysteme extends Actor {
  
  prepareBaseData() {
    super.prepareBaseData();
    
    // Le bonus de maîtrise est calculé AVANT les effets
    // car des effets pourraient le modifier
    const level = this.system.level ?? 1;
    this.system.proficiency = Math.floor((level + 7) / 4);
  }
  
  prepareDerivedData() {
    super.prepareDerivedData();
    
    // Les modificateurs finaux sont calculés APRÈS les effets
    // pour inclure les bonus/malus des buffs
    for (const [key, ability] of Object.entries(this.system.abilities)) {
      ability.mod = Math.floor((ability.value - 10) / 2);
      ability.save = ability.mod + (ability.proficient ? this.system.proficiency : 0);
    }
  }
}
⚠️ Piège courant

Si vous calculez un modificateur dans prepareBaseData(), les ActiveEffects ne pourront pas le modifier ! Placez les calculs finaux dans prepareDerivedData().

Hooks internes du Document

Les méthodes _pre* et _on* permettent d'intercepter les opérations CRUD (Create, Read, Update, Delete).

_preCreate - Avant la création

async _preCreate(data, options, user) {
  // TOUJOURS appeler super en premier
  if (await super._preCreate(data, options, user) === false) return false;
  
  // Exemple : Configurer le token prototype par défaut
  const prototypeToken = {};
  
  if (this.type === "character") {
    Object.assign(prototypeToken, {
      sight: { enabled: true },
      actorLink: true,
      disposition: CONST.TOKEN_DISPOSITIONS.FRIENDLY
    });
  }
  
  // Appliquer une taille par défaut selon la race
  const size = CONFIG.MONSYSTEME.sizes[this.system.traits?.size]?.token ?? 1;
  prototypeToken.width = size;
  prototypeToken.height = size;
  
  // updateSource modifie les données AVANT la création en base
  this.updateSource({ prototypeToken });
  
  // Ne pas retourner false = création autorisée
}

_onCreate - Après la création

_onCreate(data, options, userId) {
  super._onCreate(data, options, userId);
  
  // Effets secondaires après création
  if (this.isEmbedded && this.type === "class") {
    // Si c'est la première classe, la définir comme classe principale
    if (!this.parent.system.details?.originalClass) {
      this.parent.update({ 
        "system.details.originalClass": this.id 
      });
    }
  }
}

_preUpdate - Avant la modification

async _preUpdate(changed, options, user) {
  if (await super._preUpdate(changed, options, user) === false) return false;
  
  // Exemple : Ajuster le token si la taille change
  const newSize = foundry.utils.getProperty(changed, "system.traits.size");
  if (newSize && newSize !== this.system.traits?.size) {
    const size = CONFIG.MONSYSTEME.sizes[newSize].token ?? 1;
    changed.prototypeToken ??= {};
    changed.prototypeToken.width = size;
    changed.prototypeToken.height = size;
  }
  
  // Exemple : Empêcher les HP négatifs
  const newHP = foundry.utils.getProperty(changed, "system.attributes.hp.value");
  if (newHP !== undefined && newHP < 0) {
    changed.system.attributes.hp.value = 0;
  }
}

_preDelete - Avant la suppression

async _preDelete(options, user) {
  if (await super._preDelete(options, user) === false) return false;
  
  // Exemple : Empêcher la suppression d'items verrouillés
  if (this.flags.monsysteme?.locked) {
    ui.notifications.warn("Cet objet est verrouillé et ne peut pas être supprimé.");
    return false; // Annule la suppression
  }
  
  // Exemple : Demander confirmation pour les items importants
  if (this.type === "class" && this.isEmbedded) {
    const confirm = await Dialog.confirm({
      title: "Supprimer la classe ?",
      content: `Voulez-vous vraiment supprimer la classe ${this.name} ?`
    });
    if (!confirm) return false;
  }
}

Étendre Actor pour un système personnalisé

// module/documents/actor.mjs

/**
 * Extension de la classe Actor pour notre système
 * @extends {Actor}
 */
export default class ActorMonSysteme extends Actor {
  
  /** Icône par défaut pour les nouveaux acteurs */
  static DEFAULT_ICON = "systems/monsysteme/icons/actor.svg";
  
  /* -------------------------------------------- */
  /*  Propriétés (Getters)                        */
  /* -------------------------------------------- */
  
  /**
   * Raccourci vers les classes du personnage
   * @type {Object<string, Item>}
   */
  get classes() {
    if (this.type !== "character") return {};
    return Object.fromEntries(
      this.itemTypes.class.map(cls => [cls.identifier, cls])
    );
  }
  
  /**
   * L'armure actuellement équipée
   * @type {Item|null}
   */
  get armor() {
    return this.itemTypes.armor?.find(a => a.system.equipped) ?? null;
  }
  
  /**
   * Le personnage est-il mourant ?
   * @type {boolean}
   */
  get isDying() {
    return this.system.attributes.hp.value <= 0;
  }
  
  /* -------------------------------------------- */
  /*  Préparation des données                     */
  /* -------------------------------------------- */
  
  /** @override */
  prepareData() {
    // Réinitialiser les caches
    this._preparationWarnings = [];
    this.labels = {};
    
    super.prepareData();
    
    // Préparer les données finales des items après tout le reste
    this.items.forEach(item => item.prepareFinalAttributes?.());
  }
  
  /** @override */
  prepareBaseData() {
    super.prepareBaseData();
    // Calculs AVANT les ActiveEffects
  }
  
  /** @override */
  prepareDerivedData() {
    super.prepareDerivedData();
    // Calculs APRÈS les ActiveEffects
  }
  
  /* -------------------------------------------- */
  /*  Méthodes de jet de dés                      */
  /* -------------------------------------------- */
  
  /**
   * Effectuer un jet de compétence
   * @param {string} skillId - Identifiant de la compétence
   * @param {object} options - Options du jet
   * @returns {Promise<Roll|null>}
   */
  async rollSkill(skillId, options = {}) {
    const skill = this.system.skills?.[skillId];
    if (!skill) return null;
    
    const ability = this.system.abilities[skill.ability];
    const formula = `1d20 + ${ability.mod} + ${skill.proficient ? this.system.proficiency : 0}`;
    
    const roll = new Roll(formula, this.getRollData());
    await roll.evaluate();
    
    await roll.toMessage({
      speaker: ChatMessage.getSpeaker({ actor: this }),
      flavor: `Jet de ${CONFIG.MONSYSTEME.skills[skillId]}`
    });
    
    return roll;
  }
  
  /**
   * Effectuer un jet de sauvegarde
   * @param {string} abilityId - Identifiant de la caractéristique
   * @returns {Promise<Roll|null>}
   */
  async rollSavingThrow(abilityId, options = {}) {
    const ability = this.system.abilities?.[abilityId];
    if (!ability) return null;
    
    const formula = `1d20 + ${ability.save}`;
    const roll = new Roll(formula, this.getRollData());
    await roll.evaluate();
    
    await roll.toMessage({
      speaker: ChatMessage.getSpeaker({ actor: this }),
      flavor: `Jet de sauvegarde de ${CONFIG.MONSYSTEME.abilities[abilityId]}`
    });
    
    return roll;
  }
  
  /* -------------------------------------------- */
  /*  Gestion des points de vie                   */
  /* -------------------------------------------- */
  
  /**
   * Appliquer des dégâts à l'acteur
   * @param {number} amount - Montant de dégâts
   * @param {object} options - Options (type de dégâts, etc.)
   * @returns {Promise<Actor>}
   */
  async applyDamage(amount, options = {}) {
    const hp = this.system.attributes.hp;
    if (!hp) return this;
    
    // Appliquer d'abord aux HP temporaires
    let remaining = amount;
    let tempDamage = 0;
    
    if (hp.temp > 0) {
      tempDamage = Math.min(hp.temp, remaining);
      remaining -= tempDamage;
    }
    
    // Mettre à jour
    await this.update({
      "system.attributes.hp.temp": hp.temp - tempDamage,
      "system.attributes.hp.value": Math.max(0, hp.value - remaining)
    });
    
    return this;
  }
  
  /**
   * Soigner l'acteur
   * @param {number} amount - Montant de soins
   * @returns {Promise<Actor>}
   */
  async applyHealing(amount) {
    const hp = this.system.attributes.hp;
    if (!hp) return this;
    
    const newHP = Math.min(hp.max, hp.value + amount);
    await this.update({ "system.attributes.hp.value": newHP });
    
    return this;
  }
}

Étendre Item pour un système personnalisé

// module/documents/item.mjs

/**
 * Extension de la classe Item pour notre système
 * @extends {Item}
 */
export default class ItemMonSysteme extends Item {
  
  static DEFAULT_ICON = "systems/monsysteme/icons/item.svg";
  
  /* -------------------------------------------- */
  /*  Propriétés                                  */
  /* -------------------------------------------- */
  
  /**
   * L'item est-il équipable ?
   * @type {boolean}
   */
  get isEquippable() {
    return ["weapon", "armor", "equipment"].includes(this.type);
  }
  
  /**
   * L'item a-t-il des utilisations limitées ?
   * @type {boolean}
   */
  get hasLimitedUses() {
    return (this.system.uses?.max ?? 0) > 0;
  }
  
  /**
   * L'item nécessite-t-il un jet d'attaque ?
   * @type {boolean}
   */
  get hasAttack() {
    return ["weapon", "spell"].includes(this.type) && this.system.attack?.enabled;
  }
  
  /**
   * L'item inflige-t-il des dégâts ?
   * @type {boolean}
   */
  get hasDamage() {
    return (this.system.damage?.parts?.length ?? 0) > 0;
  }
  
  /* -------------------------------------------- */
  /*  Préparation des données                     */
  /* -------------------------------------------- */
  
  /** @override */
  prepareData() {
    super.prepareData();
    this.labels = {};
  }
  
  /** @override */
  prepareDerivedData() {
    super.prepareDerivedData();
    this._prepareLabels();
  }
  
  /**
   * Préparer les labels d'affichage
   * @private
   */
  _prepareLabels() {
    const labels = this.labels;
    
    // Type d'activation
    const activation = this.system.activation;
    if (activation?.type) {
      labels.activation = `${activation.cost ?? ""} ${CONFIG.MONSYSTEME.activationTypes[activation.type] ?? ""}`.trim();
    }
    
    // Portée
    const range = this.system.range;
    if (range?.value) {
      labels.range = `${range.value} ${range.units ?? "m"}`;
    }
    
    // Durée
    const duration = this.system.duration;
    if (duration?.value) {
      labels.duration = `${duration.value} ${CONFIG.MONSYSTEME.durationUnits[duration.units] ?? ""}`;
    }
  }
  
  /* -------------------------------------------- */
  /*  Utilisation de l'item                       */
  /* -------------------------------------------- */
  
  /**
   * Utiliser l'item (action principale)
   * @param {object} options - Options d'utilisation
   * @returns {Promise<ChatMessage|void>}
   */
  async use(options = {}) {
    // Vérifier les utilisations restantes
    if (this.hasLimitedUses) {
      const uses = this.system.uses;
      if (uses.value <= 0) {
        ui.notifications.warn(`${this.name} n'a plus d'utilisations disponibles.`);
        return;
      }
    }
    
    // Consommer une utilisation
    if (options.consumeUsage !== false && this.hasLimitedUses) {
      await this.update({
        "system.uses.value": this.system.uses.value - 1
      });
    }
    
    // Créer le message de chat
    const content = await renderTemplate(
      "systems/monsysteme/templates/chat/item-card.hbs",
      { item: this, actor: this.actor, labels: this.labels }
    );
    
    return ChatMessage.create({
      speaker: ChatMessage.getSpeaker({ actor: this.actor }),
      content,
      "flags.monsysteme.itemId": this.id
    });
  }
  
  /**
   * Effectuer un jet d'attaque
   * @returns {Promise<Roll>}
   */
  async rollAttack(options = {}) {
    if (!this.hasAttack) {
      throw new Error(`${this.name} ne possède pas de jet d'attaque.`);
    }
    
    const rollData = this.getRollData();
    const parts = ["1d20", "@mod"];
    
    if (this.system.attack?.bonus) {
      parts.push("@bonus");
      rollData.bonus = this.system.attack.bonus;
    }
    
    const formula = parts.join(" + ");
    const roll = new Roll(formula, rollData);
    await roll.evaluate();
    
    await roll.toMessage({
      speaker: ChatMessage.getSpeaker({ actor: this.actor }),
      flavor: `${this.name} - Jet d'attaque`
    });
    
    return roll;
  }
  
  /**
   * Effectuer un jet de dégâts
   * @returns {Promise<Roll>}
   */
  async rollDamage(options = {}) {
    if (!this.hasDamage) {
      throw new Error(`${this.name} n'inflige pas de dégâts.`);
    }
    
    const parts = this.system.damage.parts.map(p => p[0]);
    const formula = parts.join(" + ");
    
    const roll = new Roll(formula, this.getRollData());
    await roll.evaluate();
    
    await roll.toMessage({
      speaker: ChatMessage.getSpeaker({ actor: this.actor }),
      flavor: `${this.name} - Dégâts`
    });
    
    return roll;
  }
  
  /* -------------------------------------------- */
  /*  Hooks de cycle de vie                       */
  /* -------------------------------------------- */
  
  /** @override */
  async _preCreate(data, options, user) {
    if (await super._preCreate(data, options, user) === false) return false;
    
    // Définir une icône par défaut selon le type
    if (!data.img || data.img === this.constructor.DEFAULT_ICON) {
      const defaultIcon = CONFIG.MONSYSTEME.defaultItemIcons?.[this.type];
      if (defaultIcon) {
        this.updateSource({ img: defaultIcon });
      }
    }
  }
}

Exemple complet : Actor5e simplifié

Voici une version simplifiée inspirée du système dnd5e officiel, montrant les patterns courants.

// module/documents/actor.mjs
import SystemDocumentMixin from "./mixins/document.mjs";

/**
 * Actor étendu pour D&D 5e (simplifié)
 * @extends {Actor}
 */
export default class Actor5e extends SystemDocumentMixin(Actor) {
  
  static DEFAULT_ICON = "systems/dnd5e/icons/svg/documents/actor.svg";
  
  /* -------------------------------------------- */
  /*  Propriétés                                  */
  /* -------------------------------------------- */
  
  /** @type {Object<string, Item5e>} */
  get classes() {
    return Object.fromEntries(
      this.itemTypes.class.map(cls => [cls.identifier, cls])
    );
  }
  
  /** @type {Item5e|null} */
  get armor() {
    return this.system.attributes?.ac?.equippedArmor ?? null;
  }
  
  /** @type {Item5e|null} */
  get shield() {
    return this.system.attributes?.ac?.equippedShield ?? null;
  }
  
  /** @type {number} */
  get level() {
    if (this.type === "character") {
      return this.itemTypes.class.reduce((lvl, cls) => lvl + cls.system.levels, 0);
    }
    return this.system.details?.level ?? 0;
  }
  
  /* -------------------------------------------- */
  /*  Préparation des données                     */
  /* -------------------------------------------- */
  
  /** @override */
  prepareData() {
    this._preparationWarnings = [];
    this.labels = {};
    super.prepareData();
    this.items.forEach(item => item.prepareFinalAttributes());
  }
  
  /** @override */
  prepareBaseData() {
    // Calculer le niveau et le bonus de maîtrise
    this._prepareBaseAbilities();
  }
  
  /** @override */
  applyActiveEffects() {
    // Hook pour le DataModel
    if (this.system?.prepareEmbeddedData) {
      this.system.prepareEmbeddedData();
    }
    return super.applyActiveEffects();
  }
  
  /** @override */
  prepareDerivedData() {
    this._prepareDerivedAbilities();
    this._prepareSkills();
    this._prepareArmorClass();
  }
  
  _prepareBaseAbilities() {
    const dominated = {};
    for (const [id, abl] of Object.entries(this.system.abilities)) {
      abl.mod = Math.floor((abl.value - 10) / 2);
    }
    this.system.attributes.prof = Math.floor((this.level + 7) / 4);
  }
  
  _prepareDerivedAbilities() {
    const dominated = this.system.attributes.prof;
    for (const [id, abl] of Object.entries(this.system.abilities)) {
      abl.save = abl.mod + (abl.proficient ? dominated : 0);
      abl.dc = 8 + dominated + abl.mod;
    }
  }
  
  _prepareSkills() {
    for (const [id, skill] of Object.entries(this.system.skills ?? {})) {
      const abl = this.system.abilities[skill.ability];
      skill.mod = abl?.mod ?? 0;
      skill.total = skill.mod + (skill.proficient ? this.system.attributes.prof : 0);
    }
  }
  
  _prepareArmorClass() {
    const ac = this.system.attributes.ac;
    if (!ac) return;
    
    // CA de base : 10 + Dex
    let base = 10 + (this.system.abilities.dex?.mod ?? 0);
    
    // Armure équipée
    if (this.armor) {
      base = this.armor.system.armor.value;
      if (this.armor.system.armor.dex !== null) {
        base += Math.min(this.system.abilities.dex?.mod ?? 0, this.armor.system.armor.dex);
      }
    }
    
    // Bouclier
    if (this.shield) {
      base += this.shield.system.armor.value;
    }
    
    ac.value = base + (ac.bonus ?? 0);
  }
  
  /* -------------------------------------------- */
  /*  Actions                                     */
  /* -------------------------------------------- */
  
  /**
   * Effectuer un repos court
   * @returns {Promise<object>}
   */
  async shortRest(options = {}) {
    const hd = this.system.attributes.hd;
    const hp = this.system.attributes.hp;
    
    // Dialogue pour choisir les DV à dépenser
    // ... (simplifié)
    
    return { hp: hp.value, hd: hd.value };
  }
  
  /**
   * Effectuer un repos long
   * @returns {Promise<object>}
   */
  async longRest(options = {}) {
    const hp = this.system.attributes.hp;
    const hd = this.system.attributes.hd;
    
    const updates = {
      "system.attributes.hp.value": hp.max,
      "system.attributes.hd.value": Math.min(hd.max, hd.value + Math.floor(hd.max / 2))
    };
    
    // Récupérer les emplacements de sorts
    // ... (simplifié)
    
    await this.update(updates);
    
    // Message de chat
    await ChatMessage.create({
      speaker: ChatMessage.getSpeaker({ actor: this }),
      content: `${this.name} termine un repos long.`
    });
    
    return updates;
  }
  
  /* -------------------------------------------- */
  /*  Hooks de cycle de vie                       */
  /* -------------------------------------------- */
  
  /** @override */
  async _preCreate(data, options, user) {
    if (await super._preCreate(data, options, user) === false) return false;
    
    const prototypeToken = {};
    
    if (this.type === "character") {
      Object.assign(prototypeToken, {
        sight: { enabled: true },
        actorLink: true,
        disposition: CONST.TOKEN_DISPOSITIONS.FRIENDLY
      });
    }
    
    const size = CONFIG.DND5E.actorSizes[this.system.traits?.size]?.token ?? 1;
    prototypeToken.width = size;
    prototypeToken.height = size;
    
    this.updateSource({ prototypeToken });
  }
  
  /** @override */
  async _preUpdate(changed, options, user) {
    if (await super._preUpdate(changed, options, user) === false) return false;
    
    // Ajuster le token si la taille change
    const newSize = foundry.utils.getProperty(changed, "system.traits.size");
    if (newSize && newSize !== this.system.traits?.size) {
      const size = CONFIG.DND5E.actorSizes[newSize].token ?? 1;
      changed.prototypeToken ??= {};
      changed.prototypeToken.width = size;
      changed.prototypeToken.height = size;
    }
  }
}

Pattern Mixin pour partager du code

Un mixin permet de partager du code entre Actor et Item sans duplication.

// module/documents/mixins/document.mjs

/**
 * Mixin ajoutant des fonctionnalités communes aux documents système
 * @param {typeof Document} Base - Classe de base
 * @returns {typeof Document}
 */
export default function SystemDocumentMixin(Base) {
  return class SystemDocument extends Base {
    
    /**
     * Données de roll pour les formules de dés
     * @returns {object}
     */
    getRollData() {
      const data = { ...super.getRollData() };
      
      // Ajouter les données du DataModel
      if (this.system?.getRollData) {
        Object.assign(data, this.system.getRollData());
      }
      
      return data;
    }
    
    /**
     * Vérifier si une propriété est modifiée par des effets
     * @param {string} key - Chemin de la propriété
     * @returns {boolean}
     */
    isPropertyDerived(key) {
      return this.overrides?.[key] !== undefined;
    }
    
    /**
     * Obtenir les sources d'une modification
     * @param {string} key - Chemin de la propriété
     * @returns {ActiveEffect[]}
     */
    getPropertyAttribution(key) {
      return this.effects.filter(e => 
        e.changes.some(c => c.key === key)
      );
    }
    
    /**
     * Afficher une notification de warning
     * @param {string} message
     */
    _displayWarning(message) {
      this._preparationWarnings ??= [];
      this._preparationWarnings.push(message);
      console.warn(`${this.name}: ${message}`);
    }
  };
}

Utilisation du mixin

// Actor avec mixin
export default class Actor5e extends SystemDocumentMixin(Actor) {
  // ...
}

// Item avec mixin
export default class Item5e extends SystemDocumentMixin(Item) {
  // ...
}

Enregistrement dans CONFIG

Pour que Foundry utilise vos classes personnalisées, vous devez les enregistrer dans CONFIG lors du hook init.

// main.mjs
import Actor5e from "./module/documents/actor.mjs";
import Item5e from "./module/documents/item.mjs";
import ActiveEffect5e from "./module/documents/active-effect.mjs";
import ChatMessage5e from "./module/documents/chat-message.mjs";
import Combat5e from "./module/documents/combat.mjs";
import Combatant5e from "./module/documents/combatant.mjs";

Hooks.once("init", function() {
  console.log("Mon Système | Initialisation...");
  
  // ═══════════════════════════════════════════════════
  // ENREGISTRER LES CLASSES DE DOCUMENTS
  // ═══════════════════════════════════════════════════
  
  CONFIG.Actor.documentClass = Actor5e;
  CONFIG.Item.documentClass = Item5e;
  CONFIG.ActiveEffect.documentClass = ActiveEffect5e;
  CONFIG.ChatMessage.documentClass = ChatMessage5e;
  CONFIG.Combat.documentClass = Combat5e;
  CONFIG.Combatant.documentClass = Combatant5e;
  
  // Optionnel : Token personnalisé
  // CONFIG.Token.documentClass = TokenDocument5e;
  // CONFIG.Token.objectClass = Token5e;
  
  console.log("Mon Système | Documents enregistrés !");
});
⚠️ Timing important

L'enregistrement doit se faire dans le hook init, avant que le monde ne soit chargé. Si vous le faites plus tard, les documents existants ne bénéficieront pas de vos extensions.

💡 Vérification

Pour vérifier que votre classe est bien enregistrée, ouvrez la console et tapez CONFIG.Actor.documentClass. Vous devriez voir votre classe.

🎯 Exercice : Créer une extension d'Actor basique

Créez une classe ActorSimple qui :

  1. Calcule automatiquement le modificateur de chaque caractéristique dans prepareDerivedData()
  2. Ajoute une méthode rollAbility(abilityId) pour effectuer un jet de caractéristique
  3. Configure automatiquement le token prototype en _preCreate() :
    • Vision activée pour les "character"
    • Lien d'acteur activé pour les "character"
  4. Empêche la suppression si l'acteur a le flag protected dans _preDelete()
Voir la solution
// module/documents/actor.mjs

export default class ActorSimple extends Actor {
  
  /* -------------------------------------------- */
  /*  Préparation des données                     */
  /* -------------------------------------------- */
  
  /** @override */
  prepareDerivedData() {
    super.prepareDerivedData();
    
    // Calculer les modificateurs de caractéristiques
    const abilities = this.system.abilities ?? {};
    for (const [key, ability] of Object.entries(abilities)) {
      ability.mod = Math.floor((ability.value - 10) / 2);
    }
  }
  
  /* -------------------------------------------- */
  /*  Actions                                     */
  /* -------------------------------------------- */
  
  /**
   * Effectuer un jet de caractéristique
   * @param {string} abilityId - ID de la caractéristique (str, dex, etc.)
   * @returns {Promise<Roll|null>}
   */
  async rollAbility(abilityId) {
    const ability = this.system.abilities?.[abilityId];
    if (!ability) {
      ui.notifications.warn(`Caractéristique inconnue : ${abilityId}`);
      return null;
    }
    
    const formula = `1d20 + ${ability.mod}`;
    const roll = new Roll(formula);
    await roll.evaluate();
    
    await roll.toMessage({
      speaker: ChatMessage.getSpeaker({ actor: this }),
      flavor: `Jet de ${abilityId.toUpperCase()}`
    });
    
    return roll;
  }
  
  /* -------------------------------------------- */
  /*  Hooks de cycle de vie                       */
  /* -------------------------------------------- */
  
  /** @override */
  async _preCreate(data, options, user) {
    if (await super._preCreate(data, options, user) === false) return false;
    
    // Configurer le token prototype pour les personnages
    if (this.type === "character") {
      this.updateSource({
        prototypeToken: {
          sight: { enabled: true },
          actorLink: true,
          disposition: CONST.TOKEN_DISPOSITIONS.FRIENDLY
        }
      });
    }
  }
  
  /** @override */
  async _preDelete(options, user) {
    if (await super._preDelete(options, user) === false) return false;
    
    // Empêcher la suppression des acteurs protégés
    if (this.getFlag("monsysteme", "protected")) {
      ui.notifications.error(`${this.name} est protégé et ne peut pas être supprimé.`);
      return false;
    }
  }
}

// N'oubliez pas d'enregistrer dans main.mjs :
// CONFIG.Actor.documentClass = ActorSimple;

Ressources et documentation