Files
fvtt-celestopol/module/models/character.mjs
LeRatierBretonnier 9dbd614c5a 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>
2026-03-31 00:33:59 +02:00

298 lines
11 KiB
JavaScript

import { SYSTEM } from "../config/system.mjs"
export default class CelestopolCharacter extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
const schema = {}
// Concept du personnage
schema.concept = new fields.StringField({ required: true, nullable: false, initial: "" })
schema.metier = new fields.StringField({ required: true, nullable: false, initial: "" })
schema.faction = new fields.StringField({ required: true, nullable: false, initial: "" })
// Initiative (calculée mais stockée pour affichage)
schema.initiative = new fields.NumberField({ ...reqInt, initial: 0, min: 0 })
// Anomalie du personnage
schema.anomaly = new fields.SchemaField({
type: new fields.StringField({ required: true, nullable: false, initial: "none",
choices: Object.keys(SYSTEM.ANOMALY_TYPES) }),
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
})
// Les 4 stats avec leurs domaines — niveau stocké directement comme entier
const skillField = (label) => new fields.SchemaField({
label: new fields.StringField({ required: true, initial: label }),
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
})
const statField = (statId) => {
const skills = SYSTEM.SKILLS[statId]
const skillSchema = {}
for (const [key, skill] of Object.entries(skills)) {
skillSchema[key] = skillField(skill.label)
}
return new fields.SchemaField({
label: new fields.StringField({ required: true, initial: SYSTEM.STATS[statId].label }),
res: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
...skillSchema,
})
}
schema.stats = new fields.SchemaField({
ame: statField("ame"),
corps: statField("corps"),
coeur: statField("coeur"),
esprit: statField("esprit"),
})
// Blessures — niveau entier direct (0 = aucune, 8 = fatale)
schema.blessures = new fields.SchemaField({
lvl: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
})
// Destin — jauge entière directe
schema.destin = new fields.SchemaField({
lvl: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
})
// Spleen — jauge entière directe
schema.spleen = new fields.SchemaField({
lvl: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
})
// Attributs de personnage (Entregent, Fortune, Rêve, Vision)
const persoAttrField = () => new fields.SchemaField({
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }),
max: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }),
})
schema.attributs = new fields.SchemaField({
entregent: persoAttrField(),
fortune: persoAttrField(),
reve: persoAttrField(),
vision: persoAttrField(),
})
// Factions — score entier direct (0-9)
const factionField = () => new fields.SchemaField({
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 9 }),
})
schema.factions = new fields.SchemaField({
pinkerton: factionField(),
police: factionField(),
okhrana: factionField(),
lunanovatek: factionField(),
oto: factionField(),
syndicats: factionField(),
vorovskoymir: factionField(),
cour: factionField(),
perso1: new fields.SchemaField({
label: new fields.StringField({ required: true, nullable: false, initial: "" }),
value: new fields.NumberField({ ...reqInt, initial: 0 }),
}),
perso2: new fields.SchemaField({
label: new fields.StringField({ required: true, nullable: false, initial: "" }),
value: new fields.NumberField({ ...reqInt, initial: 0 }),
}),
})
// Préférences de jet (mémorisé entre sessions)
schema.prefs = new fields.SchemaField({
rollMoonDie: new fields.BooleanField({ required: true, initial: false }),
difficulty: new fields.StringField({ required: true, nullable: false, initial: "normal" }),
})
// Expérience
schema.xp = new fields.SchemaField({
actuel: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }),
log: new fields.ArrayField(new fields.SchemaField({
montant: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }),
raison: new fields.StringField({ required: true, nullable: false, initial: "" }),
date: new fields.StringField({ required: true, nullable: false, initial: "" }),
})),
})
// Description & notes
schema.description = new fields.HTMLField({ required: true, textSearch: true })
schema.notes = new fields.HTMLField({ required: true, textSearch: true })
// Données biographiques
schema.biodata = new fields.SchemaField({
age: new fields.StringField({ required: true, nullable: false, initial: "" }),
genre: new fields.StringField({ required: true, nullable: false, initial: "" }),
taille: new fields.StringField({ required: true, nullable: false, initial: "" }),
yeux: new fields.StringField({ required: true, nullable: false, initial: "" }),
naissance: new fields.StringField({ required: true, nullable: false, initial: "" }),
cheveux: new fields.StringField({ required: true, nullable: false, initial: "" }),
origine: new fields.StringField({ required: true, nullable: false, initial: "" }),
})
return schema
}
static LOCALIZATION_PREFIXES = ["CELESTOPOL.Character"]
prepareDerivedData() {
super.prepareDerivedData()
// Résistance par stat = +2 par domaine atteignant son seuil de spécialisation
for (const [statId, statData] of Object.entries(this.stats)) {
let res = 0
for (const [skillId, skill] of Object.entries(statData)) {
if (typeof skill !== "object" || !("value" in skill)) continue
const threshold = SYSTEM.SKILLS[statId]?.[skillId]?.resThreshold
if (threshold !== undefined && skill.value >= threshold) res += 2
}
statData.res = res
}
// Initiative PJ : 4 + Mobilité (Corps) + Inspiration (Cœur)
this.initiative = 4 + (this.stats.corps.mobilite?.value ?? 0) + (this.stats.coeur.inspiration?.value ?? 0)
// XP dépensée = somme des montants du log
this.xp.depense = this.xp.log.reduce((sum, entry) => sum + entry.montant, 0)
}
/**
* Calcule le malus de blessures actif.
* @returns {number}
*/
getWoundMalus() {
const lvl = Math.max(0, Math.min(8, this.blessures.lvl))
return SYSTEM.WOUND_LEVELS[lvl]?.malus ?? 0
}
/**
* Lance les dés pour un domaine donné.
* @param {string} statId - Id de la stat (ame, corps, coeur, esprit)
* @param {string} skillId - Id du domaine
*/
async roll(statId, skillId) {
const { CelestopolRoll } = await import("../documents/roll.mjs")
const skill = this.stats[statId][skillId]
if (!skill) return null
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorName: this.parent.name,
actorImage: this.parent.img,
statId,
skillId,
statLabel: SYSTEM.STATS[statId]?.label,
skillLabel: skill.label,
skillValue: skill.value,
woundMalus: this.getWoundMalus(),
woundLevel: this.blessures.lvl,
difficulty: this.prefs.difficulty,
rollMoonDie: this.prefs.rollMoonDie ?? false,
destGaugeFull: this.destin.lvl > 0,
fortuneValue: this.attributs.fortune.value,
})
}
/**
* Lance un test de résistance pour une stat donnée.
* Formule : 2d8 + resBonus + woundMalus
* Pas de lune, Puiser, Fortune ou Destin.
* Échec → blessure automatique.
* @param {string} statId - Id de la stat (ame, corps, coeur, esprit)
*/
async rollResistance(statId) {
const { CelestopolRoll } = await import("../documents/roll.mjs")
const statData = this.stats[statId]
if (!statData) return null
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorName: this.parent.name,
actorImage: this.parent.img,
statId,
skillId: null,
statLabel: SYSTEM.STATS[statId]?.label,
skillLabel: "CELESTOPOL.Roll.resistanceTest",
skillValue: statData.res,
woundMalus: this.getWoundMalus(),
woundLevel: this.blessures.lvl,
isResistance: true,
rollMoonDie: false,
destGaugeFull: false,
fortuneValue: 0,
difficulty: "normal",
})
}
/**
* Lance une attaque avec une arme.
* Mêlée : test Échauffourée vs Corps PNJ ; échec → blessure joueur.
* Distance : test Échauffourée vs Corps PNJ ; échec → pas de blessure joueur.
* Égalité (marge=0) → personne n'est blessé.
* @param {string} itemId - Id de l'item arme
*/
async rollAttack(itemId) {
const { CelestopolRoll } = await import("../documents/roll.mjs")
const item = this.parent.items.get(itemId)
if (!item || item.type !== "weapon") return null
const echauffouree = this.stats.corps.echauffouree
if (!echauffouree) return null
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorName: this.parent.name,
actorImage: this.parent.img,
statId: "corps",
skillId: "echauffouree",
statLabel: SYSTEM.STATS.corps.label,
skillLabel: SYSTEM.SKILLS.corps.echauffouree.label,
skillValue: echauffouree.value,
woundMalus: this.getWoundMalus(),
woundLevel: this.blessures.lvl,
rollMoonDie: this.prefs.rollMoonDie ?? false,
destGaugeFull: this.destin.lvl > 0,
fortuneValue: this.attributs.fortune.value,
isCombat: true,
isRangedDefense: false,
weaponType: item.system.type,
weaponName: item.name,
weaponDegats: item.system.degats,
})
}
/**
* Lance un jet de défense contre une attaque à distance (test Mobilité vs Corps PNJ).
* Succès → esquive réussie.
* Échec → blessure automatique (le PNJ touche).
* @param {string} itemId - Id de l'item arme (distance uniquement)
*/
async rollRangedDefense(itemId) {
const { CelestopolRoll } = await import("../documents/roll.mjs")
const item = this.parent.items.get(itemId)
if (!item || item.type !== "weapon" || item.system.type !== "distance") return null
const mobilite = this.stats.corps.mobilite
if (!mobilite) return null
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorName: this.parent.name,
actorImage: this.parent.img,
statId: "corps",
skillId: "mobilite",
statLabel: SYSTEM.STATS.corps.label,
skillLabel: SYSTEM.SKILLS.corps.mobilite.label,
skillValue: mobilite.value,
woundMalus: this.getWoundMalus(),
woundLevel: this.blessures.lvl,
rollMoonDie: this.prefs.rollMoonDie ?? false,
destGaugeFull: this.destin.lvl > 0,
fortuneValue: this.attributs.fortune.value,
isCombat: true,
isRangedDefense: true,
weaponType: "distance",
weaponName: item.name,
weaponDegats: "0",
})
}
}