223 lines
8.0 KiB
JavaScript
223 lines
8.0 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 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(),
|
||
})
|
||
}
|
||
}
|