Chapitre 2

DataModels et TypeDataModel

Les DataModels sont le système moderne de définition et validation des données dans FoundryVTT. Apprenez à définir des schémas robustes pour vos personnages et objets.

Pourquoi DataModel vs template.json ?

Avant FoundryVTT v10, les systèmes utilisaient un fichier template.json pour définir la structure des données. Cette approche a été remplacée par les DataModels, qui offrent de nombreux avantages :

Ancienne méthode (template.json) Nouvelle méthode (DataModel)
Définition en JSON statique Typage fort en JavaScript
Pas de validation Validation automatique des données
Valeurs par défaut basiques Valeurs par défaut dynamiques et intelligentes
Migration manuelle complexe Migrations intégrées avec migrateData()
Pas d'autocomplétion IDE Autocomplétion et documentation inline
💡 Recommandation

Pour tout nouveau système FoundryVTT v11+, utilisez exclusivement les DataModels. Le fichier template.json est déprécié.

Hiérarchie des classes

foundry.abstract.DataModel
    │
    ├── foundry.abstract.TypeDataModel    ← Pour les documents avec sous-types
    │       │
    │       └── SystemDataModel (personnalisé)
    │               │
    │               ├── CharacterData
    │               ├── NPCData
    │               └── WeaponData
    │
    └── Autres DataModels (settings, embedded, etc.)

DataModel vs TypeDataModel

Aspect DataModel TypeDataModel
Usage Données génériques Documents système (Actor, Item)
Accès parent this.parent this.parent (le Document)
Sous-types Non Oui (character, npc, weapon...)
💡 Quand utiliser lequel ?
  • TypeDataModel : Pour les données system des Actor, Item, ActiveEffect, ChatMessage
  • DataModel : Pour les données imbriquées, les settings, les structures personnalisées

Définir un schéma avec defineSchema()

La méthode statique defineSchema() retourne un objet décrivant la structure des données.

Syntaxe de base

// Importer les types de champs
const { 
  StringField, NumberField, BooleanField, 
  SchemaField, ArrayField, SetField,
  HTMLField, FilePathField
} = foundry.data.fields;

export default class CharacterData extends foundry.abstract.TypeDataModel {
  
  static defineSchema() {
    return {
      // Champ texte simple
      name: new StringField({
        required: true,
        blank: false,
        initial: "Nouveau Personnage"
      }),
      
      // Champ numérique
      level: new NumberField({
        required: true,
        nullable: false,
        integer: true,
        min: 1,
        max: 20,
        initial: 1
      }),
      
      // Champ booléen
      isNPC: new BooleanField({
        initial: false
      }),
      
      // Champ HTML enrichi
      biography: new HTMLField({
        required: true,
        blank: true
      }),
      
      // Objet imbriqué avec SchemaField
      attributes: new SchemaField({
        hp: new SchemaField({
          value: new NumberField({ integer: true, min: 0, initial: 10 }),
          max: new NumberField({ integer: true, min: 0, initial: 10 }),
          temp: new NumberField({ integer: true, min: 0, initial: 0 })
        }),
        ac: new NumberField({ integer: true, min: 0, initial: 10 })
      }),
      
      // Tableau de valeurs
      languages: new ArrayField(
        new StringField({ blank: false })
      ),
      
      // Ensemble (valeurs uniques)
      proficiencies: new SetField(
        new StringField({ blank: false })
      )
    };
  }
}

Types de champs disponibles

Champs primitifs

Champ Description Options clés
StringField Texte blank, trim, choices
NumberField Nombre integer, positive, min, max, step
BooleanField Booléen -
HTMLField HTML enrichi blank
FilePathField Chemin de fichier categories
ColorField Couleur hexadécimale -
JSONField Données JSON -

Champs composés

Champ Description Usage
SchemaField Objet avec sous-schéma Grouper des champs liés
ArrayField Tableau d'éléments Listes dynamiques
SetField Ensemble (valeurs uniques) Tags, proficiencies
EmbeddedDataField DataModel imbriqué Structures réutilisables
MappingField Dictionnaire clé/valeur Objets dynamiques

Champs spéciaux

Champ Description Usage
DocumentIdField ID de document Références internes
DocumentUUIDField UUID de document Références cross-compendium
ForeignDocumentField Référence à un autre document Relations

Options des champs

new NumberField({
  // ═══════════════════════════════════════
  // OPTIONS GÉNÉRALES (tous les champs)
  // ═══════════════════════════════════════
  required: true,          // Le champ doit exister
  nullable: false,         // Peut être null ?
  initial: 0,              // Valeur par défaut
  readonly: false,         // Modification interdite après création
  label: "MONSYSTEME.Level",    // Clé de traduction
  hint: "MONSYSTEME.LevelHint", // Aide contextuelle
  
  // Validation personnalisée
  validate: (value) => value > 0,
  validationError: "La valeur doit être positive",
  
  // ═══════════════════════════════════════
  // OPTIONS SPÉCIFIQUES NumberField
  // ═══════════════════════════════════════
  integer: true,           // Nombre entier uniquement
  positive: true,          // Strictement positif
  min: 0,                  // Valeur minimale
  max: 100,                // Valeur maximale
  step: 1                  // Incrément
});

new StringField({
  // ═══════════════════════════════════════
  // OPTIONS SPÉCIFIQUES StringField
  // ═══════════════════════════════════════
  blank: true,             // Chaîne vide autorisée
  trim: true,              // Supprimer les espaces
  choices: ["a", "b", "c"] // Valeurs autorisées (enum)
});
💡 Bonnes pratiques
  • Utilisez nullable: false quand possible pour éviter les null
  • Définissez toujours initial pour avoir des valeurs par défaut cohérentes
  • Utilisez label pour l'internationalisation

Exemple complet : CharacterData

// module/data/actor/character.mjs
const { 
  ArrayField, BooleanField, HTMLField, 
  NumberField, SchemaField, SetField, StringField 
} = foundry.data.fields;

export default class CharacterData extends foundry.abstract.TypeDataModel {

  // Type système pour l'enregistrement
  static _systemType = "character";

  // Définition du schéma
  static defineSchema() {
    return {
      // Informations de base
      race: new StringField({ initial: "" }),
      class: new StringField({ initial: "" }),
      level: new NumberField({ 
        integer: true, 
        min: 1, 
        max: 20, 
        initial: 1 
      }),
      
      // Points de vie
      attributes: new SchemaField({
        hp: new SchemaField({
          value: new NumberField({ 
            integer: true, 
            min: 0, 
            initial: 10 
          }),
          max: new NumberField({ 
            integer: true, 
            min: 0, 
            initial: 10 
          }),
          temp: new NumberField({ 
            integer: true, 
            min: 0, 
            initial: 0 
          })
        }),
        ac: new NumberField({ 
          integer: true, 
          min: 0, 
          initial: 10 
        }),
        initiative: new NumberField({ 
          integer: true, 
          initial: 0 
        })
      }),
      
      // Caractéristiques
      abilities: new SchemaField({
        str: this._makeAbilityField(),
        dex: this._makeAbilityField(),
        con: this._makeAbilityField(),
        int: this._makeAbilityField(),
        wis: this._makeAbilityField(),
        cha: this._makeAbilityField()
      }),
      
      // Biographie (HTML enrichi)
      biography: new HTMLField({ initial: "" }),
      
      // Langues connues
      languages: new ArrayField(
        new StringField({ blank: false })
      ),
      
      // Maîtrises
      proficiencies: new SetField(
        new StringField({ blank: false })
      ),
      
      // Ressources
      resources: new SchemaField({
        primary: this._makeResourceField(),
        secondary: this._makeResourceField()
      })
    };
  }
  
  // Helper : créer un champ de caractéristique
  static _makeAbilityField() {
    return new SchemaField({
      value: new NumberField({ 
        integer: true, 
        min: 1, 
        max: 30, 
        initial: 10 
      }),
      proficient: new BooleanField({ initial: false })
    });
  }
  
  // Helper : créer un champ de ressource
  static _makeResourceField() {
    return new SchemaField({
      value: new NumberField({ integer: true, initial: 0 }),
      max: new NumberField({ integer: true, initial: 0 }),
      label: new StringField({ initial: "" })
    });
  }
  
  // ═══════════════════════════════════════════════════════════
  // PRÉPARATION DES DONNÉES
  // ═══════════════════════════════════════════════════════════
  
  prepareBaseData() {
    // Calculer le bonus de maîtrise
    this.proficiency = Math.floor((this.level + 7) / 4);
  }
  
  prepareDerivedData() {
    // Calculer les modificateurs de caractéristiques
    for (const [key, ability] of Object.entries(this.abilities)) {
      ability.mod = Math.floor((ability.value - 10) / 2);
      ability.save = ability.mod + (ability.proficient ? this.proficiency : 0);
    }
    
    // Calculer l'initiative
    this.attributes.initiative = this.abilities.dex.mod;
  }
  
  // Données pour les jets de dés
  getRollData() {
    return {
      level: this.level,
      prof: this.proficiency,
      abilities: Object.fromEntries(
        Object.entries(this.abilities).map(([k, v]) => [k, v.mod])
      )
    };
  }
}

Système de templates (Mixins)

Pour éviter la duplication de code, vous pouvez créer des templates réutilisables entre différents types de DataModels.

Créer un template

// module/data/actor/templates/common.mjs
const { NumberField, SchemaField } = foundry.data.fields;

export default class CommonTemplate extends foundry.abstract.TypeDataModel {
  
  static defineSchema() {
    return {
      // Caractéristiques communes à tous les acteurs
      abilities: new SchemaField({
        str: this._makeAbility(),
        dex: this._makeAbility(),
        con: this._makeAbility(),
        int: this._makeAbility(),
        wis: this._makeAbility(),
        cha: this._makeAbility()
      }),
      
      // Monnaie
      currency: new SchemaField({
        pp: new NumberField({ integer: true, min: 0, initial: 0 }),
        gp: new NumberField({ integer: true, min: 0, initial: 0 }),
        sp: new NumberField({ integer: true, min: 0, initial: 0 }),
        cp: new NumberField({ integer: true, min: 0, initial: 0 })
      })
    };
  }
  
  static _makeAbility() {
    return new SchemaField({
      value: new NumberField({ integer: true, min: 1, max: 30, initial: 10 })
    });
  }
}

Utiliser le mixin

// module/data/actor/character.mjs
import CommonTemplate from "./templates/common.mjs";

// Méthode 1 : Héritage simple
export default class CharacterData extends CommonTemplate {
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      // Champs spécifiques au personnage
      level: new NumberField({ integer: true, min: 1, initial: 1 }),
      xp: new NumberField({ integer: true, min: 0, initial: 0 })
    });
  }
}

// Méthode 2 : Mixin multiple (si disponible)
export default class CharacterData extends SystemDataModel.mixin(
  CommonTemplate,
  CreatureTemplate, 
  AttributesTemplate
) {
  static defineSchema() {
    return this.mergeSchema(super.defineSchema(), {
      // Champs spécifiques
    });
  }
}

Migration des données

Quand votre schéma évolue, vous devez migrer les anciennes données. La méthode migrateData() permet de transformer automatiquement les données.

Méthode migrateData()

static migrateData(source) {
  // Exemple : Migrer l'ancien format de vitesse
  if ("speed" in source && typeof source.speed === "number") {
    source.attributes ??= {};
    source.attributes.movement ??= {};
    source.attributes.movement.walk = source.speed;
    delete source.speed;
  }
  
  // Exemple : Renommer un champ
  if ("hp" in source && !("attributes" in source)) {
    source.attributes = {
      hp: source.hp
    };
    delete source.hp;
  }
  
  // Exemple : Convertir un type de données
  if (typeof source.level === "string") {
    source.level = parseInt(source.level) || 1;
  }
  
  // Appeler la migration parente
  return super.migrateData(source);
}

Rétrocompatibilité avec shimData()

static shimData(data, options) {
  // Ajouter un getter pour l'ancien chemin (déprécié)
  this._addDataFieldShim(data, "speed", "attributes.movement.walk", {
    since: "2.0",
    until: "3.0"
  });
  
  return super.shimData(data, options);
}
⚠️ Ordre d'exécution

migrateData() est appelé avant la validation du schéma. C'est l'endroit idéal pour transformer les données anciennes vers le nouveau format.

Validation des données

Validation automatique

FoundryVTT valide automatiquement les données selon votre schéma :

// Ces données seront validées automatiquement
const actor = await Actor.create({
  name: "Test",
  type: "character",
  system: {
    level: 25  // ❌ Erreur : max est 20
  }
});

Validation personnalisée

static defineSchema() {
  return {
    hp: new SchemaField({
      value: new NumberField({ 
        validate: (value, options) => {
          const max = options?.source?.max ?? Infinity;
          if (value > max) {
            throw new Error("HP cannot exceed max HP");
          }
        }
      }),
      max: new NumberField({ min: 0 })
    })
  };
}

Validation jointe avec validateJoint()

// Valider plusieurs champs ensemble
static validateJoint(data) {
  super.validateJoint(data);
  
  if (data.hp.value > data.hp.max) {
    throw new foundry.data.validation.DataModelValidationError(
      "Current HP cannot exceed maximum HP"
    );
  }
  
  if (data.level < 1 && data.xp > 0) {
    throw new foundry.data.validation.DataModelValidationError(
      "Characters with XP must be at least level 1"
    );
  }
}

Enregistrement des DataModels

// module/data/actor/_module.mjs
import CharacterData from "./character.mjs";
import NPCData from "./npc.mjs";
import MonsterData from "./monster.mjs";

export const config = {
  character: CharacterData,
  npc: NPCData,
  monster: MonsterData
};

// module/data/item/_module.mjs
import WeaponData from "./weapon.mjs";
import ArmorData from "./armor.mjs";
import SpellData from "./spell.mjs";

export const config = {
  weapon: WeaponData,
  armor: ArmorData,
  spell: SpellData
};
// main.mjs - Hook init
import * as actorModels from "./module/data/actor/_module.mjs";
import * as itemModels from "./module/data/item/_module.mjs";

Hooks.once("init", function() {
  // Enregistrer les DataModels pour les Actors
  CONFIG.Actor.dataModels = actorModels.config;
  
  // Enregistrer les DataModels pour les Items
  CONFIG.Item.dataModels = itemModels.config;
  
  console.log("DataModels enregistrés !");
});
💡 Correspondance avec documentTypes

Les clés du dictionnaire (character, npc, etc.) doivent correspondre aux types déclarés dans documentTypes de votre system.json.

🎯 Exercice : Créer un DataModel pour un monstre

Créez un DataModel MonsterData avec les caractéristiques suivantes :

  • cr (Challenge Rating) : nombre décimal entre 0 et 30
  • size : chaîne parmi "tiny", "small", "medium", "large", "huge", "gargantuan"
  • type : chaîne (ex: "dragon", "undead", "humanoid")
  • hp : objet avec value, max, formula (string)
  • ac : objet avec value et type (string)
  • speeds : objet avec walk, fly, swim (nombres)
  • description : HTML
  • legendaryActions : nombre entier min 0
Voir la solution
// module/data/actor/monster.mjs
const { 
  NumberField, StringField, SchemaField, HTMLField 
} = foundry.data.fields;

export default class MonsterData extends foundry.abstract.TypeDataModel {
  
  static defineSchema() {
    return {
      cr: new NumberField({
        required: true,
        nullable: false,
        min: 0,
        max: 30,
        initial: 1
      }),
      
      size: new StringField({
        required: true,
        choices: ["tiny", "small", "medium", "large", "huge", "gargantuan"],
        initial: "medium"
      }),
      
      type: new StringField({
        required: true,
        initial: "humanoid"
      }),
      
      hp: new SchemaField({
        value: new NumberField({ integer: true, min: 0, initial: 10 }),
        max: new NumberField({ integer: true, min: 0, initial: 10 }),
        formula: new StringField({ initial: "2d8+2" })
      }),
      
      ac: new SchemaField({
        value: new NumberField({ integer: true, min: 0, initial: 10 }),
        type: new StringField({ initial: "natural armor" })
      }),
      
      speeds: new SchemaField({
        walk: new NumberField({ integer: true, min: 0, initial: 30 }),
        fly: new NumberField({ integer: true, min: 0, initial: 0 }),
        swim: new NumberField({ integer: true, min: 0, initial: 0 })
      }),
      
      description: new HTMLField({ initial: "" }),
      
      legendaryActions: new NumberField({
        integer: true,
        min: 0,
        initial: 0
      })
    };
  }
  
  // Calculer le bonus de maîtrise selon le CR
  prepareDerivedData() {
    if (this.cr < 5) this.proficiency = 2;
    else if (this.cr < 9) this.proficiency = 3;
    else if (this.cr < 13) this.proficiency = 4;
    else if (this.cr < 17) this.proficiency = 5;
    else if (this.cr < 21) this.proficiency = 6;
    else if (this.cr < 25) this.proficiency = 7;
    else if (this.cr < 29) this.proficiency = 8;
    else this.proficiency = 9;
  }
}

Ressources