Some checks failed
Release Creation / build (release) Failing after 1m24s
367 lines
14 KiB
JavaScript
367 lines
14 KiB
JavaScript
/**
|
||
* 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(),
|
||
})
|
||
}
|
||
}
|