Files
fvtt-celestopol/module/models/character.mjs
LeRatierBretonnier 44cc07db73
Some checks failed
Release Creation / build (release) Failing after 1m24s
Portraits et corrections sur valeurs des PNJ
2026-04-12 11:52:17 +02:00

367 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 20252026 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(),
})
}
}