feat(creation): archétypes Voyageurs + hiérarchie conteneurs + alertes

Archétypes :
- Sélection par cartes cliquables à l'étape Haut-Rêve (12 voyageurs du
  compendium + création libre), avec image et nom
- Choix haut-rêvant verrouillé quand un archétype est sélectionné
- _appliquerArchetype pré-remplit caracs, beauté, attributs, compétences
  (par _id), sorts, équipement (par nom) et fortune (total monnaies de
  l'archétype) ; image token de l'archétype à la création
- _resetArchetype (création libre) remet tout aux valeurs par défaut
- Récapitulatif : ligne Archétype

Économie des troncs :
- _economieTroncs applique la règle des compétences troncs (Corps à
  corps/Dague/Esquive et armes) au budget de 3000 XP (le Baladin passe
  de 3160 à 3000 exactement)

Hiérarchie des conteneurs :
- La structure (dont contenu des conteneurs) est prise depuis l'item
  voyageur ; mapping archetypeID -> equipID -> nouvelID créé
- Les system.contenu des conteneurs sont reconstruits après création
  (sac à dos -> 11 contenus, ceinturon -> outre -> eau, etc.)

Fix budget équipement :
- La condition de blocage (depenseDelta > 0 && > restant) permet les
  réductions même quand le budget est négatif

Alertes items non mappés :
- Les éléments de l'archétype non retrouvés dans les compendiums sont
  collectés et signalés par notification toast + bandeaux d'avertissement
  aux étapes Haut-Rêve et Équipement
This commit is contained in:
fabres
2026-08-08 09:10:46 +02:00
parent bb7466b33e
commit af05b57405
4 changed files with 359 additions and 26 deletions
+55
View File
@@ -8737,6 +8737,50 @@ body {
.system-foundryvtt-reve-de-dragon .rdd-creation-personnage .wizard-content textarea.attr-input {
box-sizing: border-box;
}
.system-foundryvtt-reve-de-dragon .rdd-creation-personnage .wizard-content .archetype-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: 0.5rem;
margin-top: 0.5rem;
}
.system-foundryvtt-reve-de-dragon .rdd-creation-personnage .wizard-content .archetype-grid .archetype-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 0.5rem;
border: 1px solid var(--color-border-light-tertiary);
border-radius: 0.3rem;
cursor: pointer;
text-align: center;
background: rgba(0, 0, 0, 0.06);
transition: background 0.15s, border-color 0.15s;
}
.system-foundryvtt-reve-de-dragon .rdd-creation-personnage .wizard-content .archetype-grid .archetype-card:hover {
background: rgba(0, 0, 0, 0.12);
}
.system-foundryvtt-reve-de-dragon .rdd-creation-personnage .wizard-content .archetype-grid .archetype-card.selected {
border-color: var(--wizard-accent-gold);
background: var(--wizard-step-active);
}
.system-foundryvtt-reve-de-dragon .rdd-creation-personnage .wizard-content .archetype-grid .archetype-card img {
width: 4.5rem;
height: 4.5rem;
object-fit: cover;
border-radius: 0.2rem;
}
.system-foundryvtt-reve-de-dragon .rdd-creation-personnage .wizard-content .archetype-grid .archetype-card .archetype-name {
font-size: 0.85rem;
line-height: 1.1;
min-height: 2.2rem;
display: flex;
align-items: center;
}
.system-foundryvtt-reve-de-dragon .rdd-creation-personnage .wizard-content .archetype-lock {
margin-left: 0.5rem;
font-style: italic;
color: var(--wizard-accent-dream);
}
.system-foundryvtt-reve-de-dragon .rdd-creation-personnage .wizard-content .equip-header {
margin-bottom: 0.5rem;
}
@@ -8830,6 +8874,17 @@ body {
width: 3rem;
text-align: center;
}
.system-foundryvtt-reve-de-dragon .rdd-creation-personnage .wizard-warning {
margin: 0.5rem 0;
}
.system-foundryvtt-reve-de-dragon .rdd-creation-personnage .wizard-warning ul {
margin: 0;
padding-left: 1.2rem;
list-style: none;
}
.system-foundryvtt-reve-de-dragon .rdd-creation-personnage .wizard-warning li {
padding: 0.15rem 0;
}
.system-foundryvtt-reve-de-dragon .rdd-creation-personnage .wizard-erreurs {
margin: 0.5rem 0;
}
+55
View File
@@ -3560,6 +3560,49 @@
.attr-input { width: 100%; }
textarea.attr-input { box-sizing: border-box; }
.archetype-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: 0.5rem;
margin-top: 0.5rem;
.archetype-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
padding: 0.5rem;
border: 1px solid var(--color-border-light-tertiary);
border-radius: 0.3rem;
cursor: pointer;
text-align: center;
background: rgba(0, 0, 0, 0.06);
transition: background 0.15s, border-color 0.15s;
&:hover { background: rgba(0, 0, 0, 0.12); }
&.selected {
border-color: var(--wizard-accent-gold);
background: var(--wizard-step-active);
}
img {
width: 4.5rem;
height: 4.5rem;
object-fit: cover;
border-radius: 0.2rem;
}
.archetype-name {
font-size: 0.85rem;
line-height: 1.1;
min-height: 2.2rem;
display: flex;
align-items: center;
}
}
}
.archetype-lock {
margin-left: 0.5rem;
font-style: italic;
color: var(--wizard-accent-dream);
}
.equip-header {
margin-bottom: 0.5rem;
.budget-labels {
@@ -3630,6 +3673,18 @@
}
/* ==================== ERREURS ==================== */
.wizard-warning {
margin: 0.5rem 0;
ul {
margin: 0;
padding-left: 1.2rem;
list-style: none;
}
li {
padding: 0.15rem 0;
}
}
.wizard-erreurs {
margin: 0.5rem 0;
ul {
@@ -1,7 +1,8 @@
import { SystemCompendiums } from "../../settings/system-compendiums.js";
import { RdDItemTete } from "../../documents/_module.mjs";
import { Grammar } from "../../grammar.js";
import { coutCompetenceCreation, coutSortCreation, sortAccessible, niveauxAccessibles } from "./cout-creation.mjs";
import { ITEM_TYPES } from "../../constants.js";
import { coutCompetenceCreation, coutPourAtteindre, coutSortCreation, sortAccessible, niveauxAccessibles } from "./cout-creation.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
@@ -17,6 +18,11 @@ const TOTAL_CARACS_CIBLE = 160;
const TOTAL_XP_PRELIMINAIRE = 3000;
const MAX_XP_SORTS = 300;
const FORTUNE_DEFAUT = 50;
const TRONCS_COMPETENCES = [
["Esquive", "Dague", "Corps à corps"],
["Epée à 1 main", "Epée à 2 mains", "Hache à 1 main", "Hache à 2 mains", "Lance", "Masse à 1 main", "Masse à 2 mains"],
];
const NIVEAU_MAX_CREATION = 3;
const CARACS_PRIMAIRES = [
@@ -63,18 +69,22 @@ export class CreationPersonnageWizard extends HandlebarsApplicationMixin(foundry
this.data = {
name: "",
hautRevant: false,
archetype: null,
caracs: Object.fromEntries(CARACS_PRIMAIRES.map(c => [c, 10])),
beaute: 10,
competences: [],
sortsChoisis: [],
attributs: { sexe: "", age: "", taille: "", poids: "", yeux: "", cheveux: "", main: "droitier", heure: "", biographie: "", description: "" },
equipement: { fortune: FORTUNE_DEFAUT, selection: {}, filtreType: "tous", recherche: "" },
equipement: { fortune: FORTUNE_DEFAUT, selection: {}, filtreType: "tous", recherche: "", nonMappes: [] },
};
this._competencesBase = [];
this._sortsDispo = [];
this._equipement = [];
this._raceHumain = null;
this._donHautReve = null;
this._voyageurs = [];
this._archetypeImg = null;
this._archetypeIdMap = new Map();
this._precharge = false;
}
@@ -133,23 +143,33 @@ export class CreationPersonnageWizard extends HandlebarsApplicationMixin(foundry
if (!this._donHautReve) {
this._donHautReve = await RdDItemTete.teteDonDeHautReve();
}
if (this._voyageurs.length === 0) {
const pack = game.packs.get("foundryvtt-reve-de-dragon.voyageurs");
if (pack) {
this._voyageurs = await pack.getDocuments();
}
}
if (this._equipement.length === 0) {
const items = await SystemCompendiums.getItems('equipement');
this._equipement = items
.filter(it => it.type !== 'monnaie')
.map(it => ({
_id: it._id,
name: it.name,
type: it.type,
img: it.img,
system: {
.map(it => {
const structure = { ...it.system };
return {
_id: it._id,
name: it.name,
type: it.type,
img: it.img,
structure,
system: {
cout: Number(it.system.cout ?? 0),
encombrement: Number(it.system.encombrement ?? 0),
quantite: it.system.quantite ?? 1,
},
cout: Number(it.system.cout ?? 0),
encombrement: Number(it.system.encombrement ?? 0),
quantite: it.system.quantite ?? 1,
},
cout: Number(it.system.cout ?? 0),
encombrement: Number(it.system.encombrement ?? 0),
}));
};
});
}
}
@@ -176,6 +196,113 @@ export class CreationPersonnageWizard extends HandlebarsApplicationMixin(foundry
}));
}
async _appliquerArchetype(voyageurId) {
const voyageur = this._voyageurs.find(v => v.id === voyageurId) || this._voyageurs.find(v => v.name === voyageurId);
if (!voyageur) {
this.data.archetype = null;
return;
}
this.data.archetype = { id: voyageur.id, name: voyageur.name };
const system = voyageur.system;
// Caractéristiques + beauté
for (const c of CARACS_PRIMAIRES) {
const v = system.carac?.[c]?.value;
if (typeof v === "number") this.data.caracs[c] = v;
}
if (typeof system.beaute === "number") this.data.beaute = system.beaute;
// Attributs physiques
for (const key of ["sexe", "age", "taille", "poids", "yeux", "cheveux", "main", "heure"]) {
if (system[key] !== undefined && system[key] !== null) {
this.data.attributs[key] = String(system[key]);
}
}
// Haut-rêvant : forcé par la présence du don
const aDonHautReve = voyageur.items.some(i => i.type === ITEM_TYPES.tete && Grammar.equalsInsensitive(i.name, "Don de haut-rêve"));
this.data.hautRevant = aDonHautReve;
// Compétences : mapper par _id (IDs identiques au compendium compétences)
const itemsCompetences = voyageur.items.filter(i => i.type === 'competence');
for (const comp of this.data.competences) {
const arItem = itemsCompetences.find(i => i._id === comp._id) || itemsCompetences.find(i => Grammar.equalsInsensitive(i.name, comp.name));
if (arItem && typeof arItem.system.niveau === "number") {
comp.system.niveau = arItem.system.niveau;
}
}
// Sorts (si HR) : charger les sorts et pré-remplir
this.data.sortsChoisis = [];
if (this.data.hautRevant) {
if (this._sortsDispo.length === 0) {
this._sortsDispo = await this._chargerSorts();
}
const sortsArchetype = voyageur.items.filter(i => i.type === 'sort');
this.data.equipement.nonMappes = [];
for (const sortAr of sortsArchetype) {
const sort = this._sortsDispo.find(s => Grammar.equalsInsensitive(s.name, sortAr.name));
if (sort && !this.data.sortsChoisis.includes(sort._id)) {
this.data.sortsChoisis.push(sort._id);
} else if (!sort) {
this.data.equipement.nonMappes.push(sortAr.name);
}
}
}
// Équipement : mapper par nom, fortune = total monnaies de l'archétype.
// La structure (dont le contenu des conteneurs) est prise depuis l'item
// du voyageur, les coûts/encombrements depuis le compendium équipement.
this.data.equipement.selection = {};
this._archetypeIdMap = new Map();
const itemsEquip = voyageur.items.filter(i => ![ITEM_TYPES.competence, ITEM_TYPES.monnaie, ITEM_TYPES.sort, ITEM_TYPES.tete].includes(i.type));
for (const item of itemsEquip) {
const equip = this._equipement.find(e => Grammar.equalsInsensitive(e.name, item.name));
if (equip) {
const quantite = Number(item.system.quantite ?? 1);
if (quantite > 0) {
const structure = { ...item.system };
delete structure._id;
this._archetypeIdMap.set(item.id, equip._id);
this.data.equipement.selection[equip._id] = {
item: { ...equip, structure },
quantite,
};
}
} else {
this.data.equipement.nonMappes.push(item.name);
}
}
const fortune = voyageur.items
.filter(i => i.type === ITEM_TYPES.monnaie)
.reduce((sum, m) => sum + (Number(m.system.cout ?? 0) * Number(m.system.quantite ?? 0)), 0);
this.data.equipement.fortune = Number(fortune.toFixed(2));
// Token / image
this._archetypeImg = voyageur.img;
if (this.data.equipement.nonMappes.length > 0) {
ui.notifications.warn(`Archétype « ${voyageur.name} » : ${this.data.equipement.nonMappes.length} élément(s) non retrouvé(s) dans les compendiums (${this.data.equipement.nonMappes.join(", ")}).`);
}
}
_resetArchetype() {
this.data.archetype = null;
this.data.caracs = Object.fromEntries(CARACS_PRIMAIRES.map(c => [c, 10]));
this.data.beaute = 10;
this.data.attributs = { sexe: "", age: "", taille: "", poids: "", yeux: "", cheveux: "", main: "droitier", heure: "", biographie: "", description: "" };
this.data.hautRevant = false;
for (const comp of this.data.competences) {
comp.system.niveau = comp.system.base;
}
this.data.sortsChoisis = [];
this.data.equipement.selection = {};
this.data.equipement.fortune = FORTUNE_DEFAUT;
this.data.equipement.nonMappes = [];
this._archetypeImg = null;
this._archetypeIdMap = new Map();
}
_onRender(context, options) {
super._onRender(context, options);
const el = this.element;
@@ -197,6 +324,17 @@ export class CreationPersonnageWizard extends HandlebarsApplicationMixin(foundry
this._precharge = false;
this.render({ force: true });
}));
el.querySelectorAll(".archetype-card").forEach(card =>
card.addEventListener("click", async event => {
const id = event.currentTarget.dataset.archetypeId;
if (!id) {
this._resetArchetype();
} else {
await this._appliquerArchetype(id);
}
this._precharge = false;
this.render({ force: true });
}));
break;
case ETAPE_CARACTERISTIQUES:
@@ -345,7 +483,7 @@ export class CreationPersonnageWizard extends HandlebarsApplicationMixin(foundry
} else {
const budget = this._budgetEquipement();
const depenseDelta = quantite * item.cout - (this.data.equipement.selection[itemId]?.quantite ?? 0) * item.cout;
if (depenseDelta > budget.restant) {
if (depenseDelta > 0 && depenseDelta > budget.restant) {
ui.notifications.warn(`Budget dépassé : ${depenseDelta.toFixed(2)} sols nécessaires, ${budget.restant.toFixed(2)} disponibles.`);
const input = el.querySelector(`input.equip-qte[data-item-id="${itemId}"]`);
if (input) input.value = this.data.equipement.selection[itemId]?.quantite ?? 0;
@@ -532,13 +670,29 @@ export class CreationPersonnageWizard extends HandlebarsApplicationMixin(foundry
if (c.isDraconic && !this.data.hautRevant) return sum;
return sum + coutCompetenceCreation(c, c.system.niveau);
}, 0);
const economieTroncs = this._economieTroncs();
const coutSorts = this._budgetSorts().depense;
return {
depense,
restant: TOTAL_XP_PRELIMINAIRE - depense - coutSorts,
depense: depense - economieTroncs,
restant: TOTAL_XP_PRELIMINAIRE - (depense - economieTroncs) - coutSorts,
};
}
_economieTroncs() {
let economie = 0;
for (const tronc of TRONCS_COMPETENCES) {
const couts = tronc
.map(nom => this.data.competences.find(c => Grammar.equalsInsensitive(c.name, nom)))
.filter(c => c)
.map(c => coutPourAtteindre(c.system.base, Math.min(c.system.niveau, 0)))
.sort((a, b) => a - b);
if (couts.length > 1) {
economie += couts.slice(0, couts.length - 1).reduce((a, b) => a + b, 0);
}
}
return economie;
}
_budgetSorts() {
if (!this.data.hautRevant) return { depense: 0, restant: MAX_XP_SORTS };
const depense = this.data.sortsChoisis.reduce((sum, id) => {
@@ -640,6 +794,7 @@ export class CreationPersonnageWizard extends HandlebarsApplicationMixin(foundry
const actor = await Actor.create({
name: this.data.name || "Nouveau personnage",
type: 'personnage',
img: this._archetypeImg,
system: {
carac: caracsData,
beaute: this.data.beaute,
@@ -724,18 +879,48 @@ export class CreationPersonnageWizard extends HandlebarsApplicationMixin(foundry
async _creerEquipementEtFortune(actor) {
const selection = Object.values(this.data.equipement.selection).filter(sel => sel.quantite > 0);
if (selection.length > 0) {
const items = selection.map(sel => {
// Créer les items individuellement (les IDs sont générés par Foundry)
// puis mapper ancien ID compendium -> nouveau ID par nom pour reconstruire
// la hiérarchie des conteneurs.
const itemsCrees = [];
for (const sel of selection) {
const item = this._equipement.find(it => it._id === sel.item._id);
if (!item) return null;
return {
if (!item) continue;
const system = {
quantite: sel.quantite,
cout: item.cout,
encombrement: item.encombrement,
...(item.structure ? { ...item.structure } : {}),
};
delete system.contenu; // recréé après le mapping
delete system._id;
const created = await actor.createEmbeddedDocuments('Item', [{
name: item.name,
type: item.type,
img: item.img,
system: { quantite: sel.quantite, cout: item.cout, encombrement: item.encombrement },
};
}).filter(it => it);
if (items.length > 0) {
await actor.createEmbeddedDocuments('Item', items);
system,
}]);
if (created[0]) {
itemsCrees.push({ ancienId: item._id, nouveau: created[0] });
}
}
const idMap = new Map(itemsCrees.map(c => [c.ancienId, c.nouveau.id]));
// Mapper les contenus des conteneurs vers les nouveaux IDs
const conteneurs = Object.values(this.data.equipement.selection)
.map(sel => sel.item)
.filter(item => item.type === 'conteneur' && Array.isArray(item.structure?.contenu) && item.structure.contenu.length > 0);
for (const conteneur of conteneurs) {
const nouveauConteneur = actor.items.get(idMap.get(conteneur._id));
if (!nouveauConteneur) continue;
const nouveauxContenus = conteneur.structure.contenu
.map(ancienIdEnfant => {
const equipId = this._archetypeIdMap.get(ancienIdEnfant) ?? ancienIdEnfant;
return idMap.get(equipId);
})
.filter(id => id);
if (nouveauxContenus.length > 0) {
await nouveauConteneur.update({ 'system.contenu': nouveauxContenus });
}
}
}
const budget = this._budgetEquipement();
@@ -771,6 +956,7 @@ export class CreationPersonnageWizard extends HandlebarsApplicationMixin(foundry
TOTAL_XP_PRELIMINAIRE,
MAX_XP_SORTS,
FORTUNE_DEFAUT,
voyageurs: this._voyageurs.map(v => ({ id: v.id, name: v.name, img: v.img })),
};
switch (this.step) {
case ETAPE_COMPETENCES:
+39 -2
View File
@@ -21,9 +21,35 @@
<p>Avant de commencer la création, choix fondamental : votre personnage sera-t-il haut-rêvant (magicien) ou non ? Le don de haut-rêve s'acquiert dans l'enfance ; un personnage adulte n'aura pratiquement aucune chance de le devenir ensuite.</p>
<p>Le principe de création est identique, mais les haut-rêvants doivent réserver des points pour les compétences de magie (Draconic) et pour les sorts.</p>
<div class="form-group">
<label><input type="radio" name="hautRevant" value="false" {{#unless data.hautRevant}}checked{{/unless}} /> Non haut-rêvant</label>
<label><input type="radio" name="hautRevant" value="true" {{#if data.hautRevant}}checked{{/if}} /> Haut-rêvant</label>
<label><input type="radio" name="hautRevant" value="false" {{#unless data.hautRevant}}checked{{/unless}} {{#if data.archetype}}disabled{{/if}} /> Non haut-rêvant</label>
<label><input type="radio" name="hautRevant" value="true" {{#if data.hautRevant}}checked{{/if}} {{#if data.archetype}}disabled{{/if}} /> Haut-rêvant</label>
{{#if data.archetype}}<span class="archetype-lock">(archétype sélectionné : choix verrouillé)</span>{{/if}}
</div>
<h3>Archétype (Voyageurs)</h3>
<p>Vous pouvez bâtir votre personnage sur un archétype du compendium Voyageurs : ses caractéristiques, compétences, sorts, équipement et fortune seront pré-remplis, puis personnalisables.</p>
<div class="archetype-grid">
<div class="archetype-card {{#unless data.archetype}}selected{{/unless}}" data-archetype-id="">
<img src="systems/foundryvtt-reve-de-dragon/icons/voyageurs/personnage.webp" alt="Aucun" />
<span class="archetype-name">Création libre</span>
</div>
{{#each voyageurs}}
<div class="archetype-card {{#if (eq ../data.archetype.id this.id)}}selected{{/if}}" data-archetype-id="{{this.id}}">
<img src="{{this.img}}" alt="{{this.name}}" />
<span class="archetype-name">{{this.name}}</span>
</div>
{{/each}}
</div>
{{#if data.equipement.nonMappes.length}}
<div class="wizard-warning notification warning">
<strong>Éléments de l'archétype « {{data.archetype.name}} » non retrouvés dans les compendiums :</strong>
<ul>
{{#each data.equipement.nonMappes}}
<li>{{this}}</li>
{{/each}}
</ul>
</div>
{{/if}}
{{/if}}
{{!-- Étape 1 : Caractéristiques --}}
@@ -181,6 +207,16 @@
{{#if (eq step 5)}}
<h3>Équipement & Richesse de départ</h3>
<p>Votre personnage commence avec une fortune de {{FORTUNE_DEFAUT}} sols (ajustable). Achetez vos vêtements, armes et équipement, puis gardez le reste en espèces.</p>
{{#if data.equipement.nonMappes.length}}
<div class="wizard-warning notification warning">
<strong>Éléments de l'archétype non retrouvés dans les compendiums :</strong>
<ul>
{{#each data.equipement.nonMappes}}
<li>{{this}}</li>
{{/each}}
</ul>
</div>
{{/if}}
<div class="equip-header">
<div class="form-group">
<label>Fortune (sols)</label>
@@ -244,6 +280,7 @@
<h4>Identité</h4>
<p><strong>Nom :</strong> {{data.name}}</p>
<p><strong>Haut-rêvant :</strong> {{#if data.hautRevant}}Oui{{else}}Non{{/if}}</p>
{{#if data.archetype}}<p><strong>Archétype :</strong> {{data.archetype.name}}</p>{{/if}}
</div>
<div class="recap-section">
<h4>Caractéristiques</h4>