Files
fvtt-celestopol/module/models/npc.mjs

223 lines
8.0 KiB
JavaScript
Raw 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 CelestopolNPC extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
const schema = {}
schema.npcType = new fields.StringField({ required: true, nullable: false, initial: "standard",
choices: Object.keys(SYSTEM.NPC_TYPES) })
schema.concept = new fields.StringField({ required: true, nullable: false, initial: "" })
schema.faction = new fields.StringField({ required: true, nullable: false, initial: "" })
schema.initiative = new fields.NumberField({ ...reqInt, initial: 0, min: 0 })
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 }),
})
// PNJs : 4 domaines uniquement (pas de sous-compétences)
const domainField = (statId) => new fields.SchemaField({
label: new fields.StringField({ required: true, initial: SYSTEM.STATS[statId].label }),
res: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }),
actuel: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }),
})
schema.stats = new fields.SchemaField({
ame: domainField("ame"),
corps: domainField("corps"),
coeur: domainField("coeur"),
esprit: domainField("esprit"),
})
schema.blessures = new fields.SchemaField({
lvl: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
})
schema.histoire = new fields.HTMLField({ required: true, textSearch: true })
schema.descriptionPhysique = new fields.HTMLField({ required: true, textSearch: true })
schema.portraitImage = new fields.StringField({ required: true, nullable: false, initial: "" })
schema.notes = new fields.HTMLField({ required: true, textSearch: true })
return schema
}
static LOCALIZATION_PREFIXES = ["CELESTOPOL.NPC"]
prepareDerivedData() {
super.prepareDerivedData()
const malus = this.getWoundMalus()
// Initiative PNJ : valeur du Domaine Corps (avec malus blessures)
this.initiative = Math.max(0, this.stats.corps.res + malus)
for (const stat of Object.values(this.stats)) {
stat.actuel = Math.max(0, stat.res + malus)
}
this.armorMalus = this.getArmorMalus()
}
getWoundMalus() {
const lvl = Math.max(0, Math.min(8, this.blessures.lvl))
return SYSTEM.WOUND_LEVELS[lvl]?.malus ?? 0
}
/** Somme des malus des armures équipées (valeur négative ou 0). */
getArmorMalus() {
const armures = this.parent?.itemTypes?.armure ?? []
return armures
.filter(a => a.system.equipped)
.reduce((sum, a) => {
const value = a.system.protection ?? a.system.malus
return sum + (value ? -Math.abs(value) : 0)
}, 0)
}
/**
* Retourne le malus d'armure applicable pour un jet PNJ.
* Règle : sur tous les jets de Corps uniquement.
* @param {string} statId
* @returns {number}
*/
getArmorMalusForRoll(statId) {
return statId === "corps" ? this.getArmorMalus() : 0
}
/**
* Lance un jet sur un domaine (Âme/Corps/Cœur/Esprit).
* Le label affiché tient compte du type de PNJ (standard vs antagoniste).
*/
async roll(statId) {
const { CelestopolRoll } = await import("../documents/roll.mjs")
const statData = this.stats[statId]
if (!statData) return null
const isAntagoniste = this.npcType === "antagoniste"
const skillLabel = isAntagoniste
? SYSTEM.ANTAGONISTE_STATS[statId]?.label
: SYSTEM.STATS[statId]?.label
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorUuid: this.parent.uuid,
actorName: this.parent.name,
actorImage: this.parent.img,
statId,
skillLabel,
skillValue: statData.res,
woundMalus: this.getWoundMalus(),
armorMalus: this.getArmorMalusForRoll(statId),
woundLevel: this.blessures.lvl,
})
}
/** Alias pour compatibilité avec le handler _onRoll (clic sans skillId). */
async rollResistance(statId) {
return this.roll(statId)
}
/**
* Collecte les cibles protagonistes de la scène active pour les jets de combat PNJ.
* @returns {Array<{id:string, name:string, corps:number}>}
*/
_getCombatTargets() {
const toEntry = actor => ({
id: actor.id,
uuid: actor.uuid,
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 === "character" && t.actor.id !== this.parent.id)
.map(t => {
const actor = t.actor
return [actor.id, toEntry(actor)]
})).values()]
}
/**
* Lance une attaque PNJ avec une arme.
* Le test utilise le domaine Corps et transmet explicitement les dégâts de l'arme.
* @param {string} itemId
* @returns {Promise<import("../documents/roll.mjs").CelestopolRoll|null>}
*/
async rollAttack(itemId) {
const { CelestopolRoll } = await import("../documents/roll.mjs")
const item = this.parent.items.get(itemId)
if (!item || item.type !== "weapon") return null
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorUuid: this.parent.uuid,
actorName: this.parent.name,
actorImage: this.parent.img,
statId: "corps",
skillId: null,
statLabel: SYSTEM.STATS.corps.label,
skillLabel: "CELESTOPOL.Combat.attack",
skillValue: this.stats.corps.res,
woundMalus: this.getWoundMalus(),
armorMalus: this.getArmorMalusForRoll("corps"),
woundLevel: this.blessures.lvl,
rollMoonDie: false,
destGaugeFull: false,
fortuneValue: 0,
isCombat: true,
isRangedDefense: false,
weaponType: item.system.type,
weaponName: item.name,
weaponDegats: item.system.degats,
availableTargets: this._getCombatTargets(),
})
}
/**
* Lance un jet de tir/esquive PNJ avec une arme à distance.
* @param {string} itemId
* @returns {Promise<import("../documents/roll.mjs").CelestopolRoll|null>}
*/
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
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorUuid: this.parent.uuid,
actorName: this.parent.name,
actorImage: this.parent.img,
statId: "corps",
skillId: null,
statLabel: SYSTEM.STATS.corps.label,
skillLabel: "CELESTOPOL.Combat.rangedDefenseTitle",
skillValue: this.stats.corps.res,
woundMalus: this.getWoundMalus(),
armorMalus: this.getArmorMalusForRoll("corps"),
woundLevel: this.blessures.lvl,
rollMoonDie: false,
destGaugeFull: false,
fortuneValue: 0,
isCombat: true,
isRangedDefense: true,
weaponType: "distance",
weaponName: item.name,
weaponDegats: item.system.degats,
availableTargets: this._getCombatTargets(),
})
}
}