/** * Célestopol 1922 — Système FoundryVTT * * Célestopol 1922 est un jeu de rôle édité par Antre-Monde Éditions. * Ce système FoundryVTT est une implémentation indépendante et n'est pas * affilié à Antre-Monde Éditions, * mais a été réalisé avec l'autorisation d'Antre-Monde Éditions. * * @author LeRatierBretonnien * @copyright 2025–2026 LeRatierBretonnien * @license CC BY-NC-SA 4.0 – https://creativecommons.org/licenses/by-nc-sa/4.0/ */ 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 — niveau de relation -4 (hostile) à +4 (allié), 0 = neutre const factionField = () => new fields.SchemaField({ value: new fields.NumberField({ ...reqInt, initial: 0, min: -4, max: 4 }), }) 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, min: -4, max: 4 }), }), perso2: new fields.SchemaField({ label: new fields.StringField({ required: true, nullable: false, initial: "" }), value: new fields.NumberField({ ...reqInt, initial: 0, min: -4, max: 4 }), }), }) // 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.descriptionPhysique = new fields.HTMLField({ required: true, textSearch: true }) schema.descriptionPsychologique = new fields.HTMLField({ required: true, textSearch: true }) schema.historique = new fields.HTMLField({ required: true, initial: "", textSearch: true }) schema.portraitImage = new fields.StringField({ required: true, nullable: false, initial: "" }) 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) // Malus d'armure(s) équipée(s) this.armorMalus = this.getArmorMalus() } /** * Retourne le malus total des armures équipées portées par le protagoniste. * @returns {number} */ getArmorMalus() { if (!this.parent) return 0 return -(this.parent.itemTypes.armure .filter(a => a.system.equipped && (a.system.protection ?? a.system.malus) > 0) .reduce((sum, a) => sum + (a.system.protection ?? a.system.malus), 0)) } /** * Retourne le malus d'armure applicable pour un jet PJ. * Règle : uniquement sur Mobilité et Effacement si l'armure est équipée. * @param {string} statId * @param {string|null} skillId * @returns {number} */ getArmorMalusForRoll(statId, skillId = null) { if (statId !== "corps") return 0 if (!["mobilite", "effacement"].includes(skillId)) return 0 return this.getArmorMalus() } /** * 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(), armorMalus: this.getArmorMalusForRoll(statId, skillId), 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(), armorMalus: 0, woundLevel: this.blessures.lvl, isResistance: true, rollMoonDie: false, destGaugeFull: false, fortuneValue: 0, difficulty: "normal", }) } /** * Collecte les cibles de combat sur la scène active. * Pour un PJ attaquant, seules les cibles PNJ présentes sur la scène sont proposées. * @returns {Array<{id:string, name:string, corps:number}>} */ _getCombatTargets() { const toEntry = actor => ({ id: actor.id, name: actor.name, corps: actor.system.stats?.corps?.res ?? 0, }) const sceneTokens = canvas?.scene?.isView ? (canvas.tokens?.placeables ?? []) : [] return [...new Map(sceneTokens .filter(t => t.actor?.type === "npc" && t.actor.id !== this.parent.id) .map(t => { const actor = t.actor return [actor.id, toEntry(actor)] })).values()] } /** * 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(), armorMalus: this.getArmorMalusForRoll("corps", "echauffouree"), 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, availableTargets: this._getCombatTargets(), }) } /** * 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(), armorMalus: this.getArmorMalusForRoll("corps", "mobilite"), 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", availableTargets: this._getCombatTargets(), }) } }