feat: gestion de l'expérience (XP)

- Schéma xp dans CelestopolCharacter : actuel (éditable), log[] ({montant, raison, date}), depense (calculé dans prepareDerivedData)
- Bouton 'Dépenser XP' → DialogV2 (montant + raison) : décrémente actuel, logge l'entrée
- Suppression d'entrée de log avec remboursement des points (mode édition)
- Section XP en haut de l'onglet Biographie : compteurs, tableau du log, référentiel des coûts
- i18n : section CELESTOPOL.XP.* complète
- CSS : .xp-section avec compteurs, tableau de log et accordéon de référence

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-03-31 00:33:59 +02:00
parent 79a68ee9ab
commit 9dbd614c5a
40 changed files with 849 additions and 529 deletions

View File

@@ -68,7 +68,7 @@ Hooks.once("init", () => {
// ── Sheets: unregister core, register system sheets ─────────────────────
foundry.applications.sheets.ActorSheetV2.unregisterSheet?.("core", "Actor", { types: ["character", "npc"] })
foundry.documents.collections.Actors.unregisterSheet("core", foundry.appv1.sheets.ActorSheet)
foundry.appv1?.sheets?.ActorSheet && foundry.documents.collections.Actors.unregisterSheet("core", foundry.appv1.sheets.ActorSheet)
foundry.documents.collections.Actors.registerSheet(SYSTEM_ID, CelestopolCharacterSheet, {
types: ["character"],
makeDefault: true,
@@ -80,7 +80,7 @@ Hooks.once("init", () => {
label: "CELESTOPOL.Sheet.npc",
})
foundry.documents.collections.Items.unregisterSheet("core", foundry.appv1.sheets.ItemSheet)
foundry.appv1?.sheets?.ItemSheet && foundry.documents.collections.Items.unregisterSheet("core", foundry.appv1.sheets.ItemSheet)
foundry.documents.collections.Items.registerSheet(SYSTEM_ID, CelestopolAnomalySheet, {
types: ["anomaly"],
makeDefault: true,
@@ -130,6 +130,7 @@ Hooks.once("ready", () => {
// Migration : supprime les items de types obsolètes (ex: "attribute")
if (game.user.isGM) {
_migrateObsoleteItems()
_migrateIntegerTracks()
}
})
@@ -156,6 +157,68 @@ async function _migrateObsoleteItems() {
}
}
/**
* Migration : convertit les anciennes données booléennes (level1..level8, b1..b8, etc.)
* vers le nouveau stockage entier direct.
* Ne s'applique qu'aux acteurs ayant encore l'ancien format dans leur source.
*/
async function _migrateIntegerTracks() {
const validActors = game.actors.contents.filter(a => ["character", "npc"].includes(a.type))
for (const actor of validActors) {
const src = actor._source?.system
if (!src) continue
const updateData = {}
// Blessures : si b1 existe dans la source, recalculer lvl depuis les booléens
const blessures = src.blessures ?? {}
if ("b1" in blessures) {
const lvl = [1,2,3,4,5,6,7,8].filter(i => blessures[`b${i}`]?.checked === true).length
updateData["system.blessures.lvl"] = lvl
}
if (actor.type === "character") {
// Destin
const destin = src.destin ?? {}
if ("d1" in destin) {
const lvl = [1,2,3,4,5,6,7,8].filter(i => destin[`d${i}`]?.checked === true).length
updateData["system.destin.lvl"] = lvl
}
// Spleen
const spleen = src.spleen ?? {}
if ("s1" in spleen) {
const lvl = [1,2,3,4,5,6,7,8].filter(i => spleen[`s${i}`]?.checked === true).length
updateData["system.spleen.lvl"] = lvl
}
// Domaines : si level1 existe dans un domaine, recalculer value depuis les booléens
const stats = src.stats ?? {}
for (const [statId, statData] of Object.entries(stats)) {
for (const [skillId, skill] of Object.entries(statData ?? {})) {
if (typeof skill !== "object" || !("level1" in skill)) continue
const value = [1,2,3,4,5,6,7,8].filter(i => skill[`level${i}`] === true).length
updateData[`system.stats.${statId}.${skillId}.value`] = value
}
}
// Factions : si level1 existe dans une faction, recalculer value depuis les booléens
const factions = src.factions ?? {}
for (const [factionId, faction] of Object.entries(factions)) {
if (typeof faction !== "object" || !("level1" in faction)) continue
const value = [1,2,3,4,5,6,7,8,9].filter(i => faction[`level${i}`] === true).length
updateData[`system.factions.${factionId}.value`] = value
}
}
if (Object.keys(updateData).length > 0) {
console.log(`${SYSTEM_ID} | Migration tracks → entiers : ${actor.name}`, updateData)
await actor.update(updateData)
}
}
}
/* ─── Handlebars helpers ─────────────────────────────────────────────────── */
function _registerHandlebarsHelpers() {
@@ -183,6 +246,9 @@ function _registerHandlebarsHelpers() {
// Helper : build array from args (Handlebars doesn't have arrays natively)
Handlebars.registerHelper("array", (...args) => args.slice(0, -1))
// Helper : range(n) → [1, 2, ..., n] — pour les boucles de cases à cocher
Handlebars.registerHelper("range", (n) => Array.from({ length: n }, (_, i) => i + 1))
// Helper : nested object lookup with dot path or multiple keys
Handlebars.registerHelper("lookup", (obj, ...args) => {
const options = args.pop() // last arg is Handlebars options hash