/**
* 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, SYSTEM_ID, ASCII } from "./module/config/system.mjs"
import {
CelestopolCharacter,
CelestopolNPC,
CelestopolAnomaly,
CelestopolAspect,
CelestopolEquipment,
CelestopolWeapon,
CelestopolArmure,
} from "./module/models/_module.mjs"
import {
CelestopolActor,
CelestopolItem,
CelestopolChatMessage,
CelestopolCombat,
CelestopolRoll,
} from "./module/documents/_module.mjs"
import {
CelestopolCharacterSheet,
CelestopolNPCSheet,
CelestopolAnomalySheet,
CelestopolAspectSheet,
CelestopolEquipmentSheet,
CelestopolWeaponSheet,
CelestopolArmureSheet,
} from "./module/applications/_module.mjs"
const DAMAGE_APPLICATION_FLAG = "damageApplication"
const MOON_EFFECT_FLAG = "moonEffectApplied"
const FACTION_ASPECT_STATE_SETTING = "factionAspectState"
const PREGENS_IMPORTED_SETTING = "pregensImported"
const WELCOME_SCENE_IMPORTED_SETTING = "welcomeSceneImported"
/* ─── Init hook ──────────────────────────────────────────────────────────── */
Hooks.once("init", () => {
console.log(ASCII)
console.log(`${SYSTEM_ID} | Initializing Célestopol 1922 system`)
// Logo de pause : patch de GamePause._prepareContext pour remplacer l'icône
const GamePause = foundry.applications.hud?.GamePause ?? globalThis.GamePause
if (GamePause) {
const _origCtx = GamePause.prototype._prepareContext
GamePause.prototype._prepareContext = async function(options) {
const ctx = await _origCtx.call(this, options)
ctx.icon = "systems/fvtt-celestopol/assets/ui/logo_jeu.png"
ctx.spin = false
return ctx
}
}
// Expose SYSTEM constants + utilities globales
game.celestopol = {
SYSTEM,
rollMoonStandalone: (actor = null) => CelestopolRoll.rollMoonStandalone(actor),
manageFactionAspects: (actor = null) => _manageFactionAspects(actor),
resetFactionAspects: () => _resetFactionAspects(),
getFactionAspectState: () => _getFactionAspectState(),
getFactionAspectSummary: (actor = null) => _getFactionAspectSummary(actor),
getFactionDisplayLabel: (value) => _getFactionDisplayLabel(value),
normalizeFactionId: (value) => _normalizeFactionId(value),
}
// ── DataModels ──────────────────────────────────────────────────────────
CONFIG.Actor.dataModels.character = CelestopolCharacter
CONFIG.Actor.dataModels.npc = CelestopolNPC
CONFIG.Item.dataModels.anomaly = CelestopolAnomaly
CONFIG.Item.dataModels.aspect = CelestopolAspect
CONFIG.Item.dataModels.equipment = CelestopolEquipment
CONFIG.Item.dataModels.weapon = CelestopolWeapon
CONFIG.Item.dataModels.armure = CelestopolArmure
// ── Document classes ────────────────────────────────────────────────────
CONFIG.Actor.documentClass = CelestopolActor
CONFIG.Item.documentClass = CelestopolItem
CONFIG.ChatMessage.documentClass = CelestopolChatMessage
CONFIG.Combat.documentClass = CelestopolCombat
CONFIG.Dice.rolls.push(CelestopolRoll)
// ── Initiative déterministe (pas de dé) ─────────────────────────────────
// Formule de secours si Combat.rollInitiative est appelé sans passer par notre override
CONFIG.Combat.initiative = { formula: "@initiative", decimals: 0 }
// ── Token display defaults ───────────────────────────────────────────────
CONFIG.Actor.trackableAttributes = {
character: {
bar: ["blessures.lvl"],
value: ["initiative", "anomaly.level"],
},
npc: {
bar: ["blessures.lvl"],
value: ["initiative"],
},
}
// ── Sheets: unregister core, register system sheets ─────────────────────
foundry.applications.sheets.ActorSheetV2.unregisterSheet?.("core", "Actor", { types: ["character", "npc"] })
foundry.appv1?.sheets?.ActorSheet && foundry.documents.collections.Actors.unregisterSheet("core", foundry.appv1.sheets.ActorSheet)
foundry.documents.collections.Actors.registerSheet(SYSTEM_ID, CelestopolCharacterSheet, {
types: ["character"],
makeDefault: true,
label: "CELESTOPOL.Sheet.character",
})
foundry.documents.collections.Actors.registerSheet(SYSTEM_ID, CelestopolNPCSheet, {
types: ["npc"],
makeDefault: true,
label: "CELESTOPOL.Sheet.npc",
})
foundry.appv1?.sheets?.ItemSheet && foundry.documents.collections.Items.unregisterSheet("core", foundry.appv1.sheets.ItemSheet)
foundry.documents.collections.Items.registerSheet(SYSTEM_ID, CelestopolAnomalySheet, {
types: ["anomaly"],
makeDefault: true,
label: "CELESTOPOL.Sheet.anomaly",
})
foundry.documents.collections.Items.registerSheet(SYSTEM_ID, CelestopolAspectSheet, {
types: ["aspect"],
makeDefault: true,
label: "CELESTOPOL.Sheet.aspect",
})
foundry.documents.collections.Items.registerSheet(SYSTEM_ID, CelestopolEquipmentSheet, {
types: ["equipment"],
makeDefault: true,
label: "CELESTOPOL.Sheet.equipment",
})
foundry.documents.collections.Items.registerSheet(SYSTEM_ID, CelestopolWeaponSheet, {
types: ["weapon"],
makeDefault: true,
label: "CELESTOPOL.Sheet.weapon",
})
foundry.documents.collections.Items.registerSheet(SYSTEM_ID, CelestopolArmureSheet, {
types: ["armure"],
makeDefault: true,
label: "CELESTOPOL.Sheet.armure",
})
// ── Handlebars helpers ───────────────────────────────────────────────────
_registerHandlebarsHelpers()
// ── Game settings ────────────────────────────────────────────────────────
_registerSettings()
// ── Pre-load templates ───────────────────────────────────────────────────
_preloadTemplates()
})
/* ─── Ready hook ─────────────────────────────────────────────────────────── */
Hooks.once("ready", async () => {
console.log(`${SYSTEM_ID} | System ready`)
// Socket handler for GM-only operations (e.g. wound application)
if (game.socket) {
game.socket.on(`system.${SYSTEM_ID}`, _onSocketMessage)
}
Hooks.on("renderChatMessageHTML", (message, html) => {
_activateChatCardListeners(message, html)
})
Hooks.on("updateChatMessage", (message, changed) => {
if (foundry.utils.hasProperty(changed, `flags.${SYSTEM_ID}.${DAMAGE_APPLICATION_FLAG}`)) {
_updateRenderedChatMessageState(message)
}
if (foundry.utils.hasProperty(changed, `flags.${SYSTEM_ID}.${MOON_EFFECT_FLAG}`)) {
_updateRenderedMoonEffectState(message)
}
})
_activateExistingChatCards()
// Migration : supprime les items de types obsolètes (ex: "attribute")
if (game.user.isGM) {
_migrateObsoleteItems()
_migrateIntegerTracks()
_migrateBiographyFields()
await _setupAnomaliesFolder()
await _setupPregensFolder()
await _setupWelcomeScene()
}
await _createWelcomeChatMessage()
})
/** Supprime les items dont le type n'est plus reconnu par le système. */
async function _migrateObsoleteItems() {
const validTypes = new Set(["anomaly", "aspect", "equipment", "weapon", "armure"])
for (const actor of game.actors) {
// Utilise _source.items pour trouver les items qui n'ont pas pu s'initialiser
const toDelete = (actor._source?.items ?? [])
.filter(i => !validTypes.has(i.type))
.map(i => i._id)
if (toDelete.length) {
console.warn(`${SYSTEM_ID} | Migration: suppression de ${toDelete.length} item(s) obsolète(s) sur ${actor.name}`, toDelete)
await actor.deleteEmbeddedDocuments("Item", toDelete)
}
}
// Items globaux (hors acteur)
const globalToDelete = game.items.contents
.filter(i => !validTypes.has(i.type))
.map(i => i.id)
if (globalToDelete.length) {
console.warn(`${SYSTEM_ID} | Migration: suppression de ${globalToDelete.length} item(s) global(aux) obsolète(s)`, globalToDelete)
await Item.deleteDocuments(globalToDelete)
}
}
/**
* Migration : convertit les anciennes données booléennes (level1..level8, b1..b8, etc.)
* vers le nouveau stockage entier direct.
* Ne s'applique qu'aux acteurs ayant encore l'ancien format dans leur source.
*/
async function _migrateIntegerTracks() {
const validActors = game.actors.contents.filter(a => ["character", "npc"].includes(a.type))
for (const actor of validActors) {
const src = actor._source?.system
if (!src) continue
const updateData = {}
// Blessures : si b1 existe dans la source, recalculer lvl depuis les booléens
const blessures = src.blessures ?? {}
if ("b1" in blessures) {
const lvl = [1,2,3,4,5,6,7,8].filter(i => blessures[`b${i}`]?.checked === true).length
updateData["system.blessures.lvl"] = lvl
}
if (actor.type === "character") {
// Destin
const destin = src.destin ?? {}
if ("d1" in destin) {
const lvl = [1,2,3,4,5,6,7,8].filter(i => destin[`d${i}`]?.checked === true).length
updateData["system.destin.lvl"] = lvl
}
// Spleen
const spleen = src.spleen ?? {}
if ("s1" in spleen) {
const lvl = [1,2,3,4,5,6,7,8].filter(i => spleen[`s${i}`]?.checked === true).length
updateData["system.spleen.lvl"] = lvl
}
// Domaines : si level1 existe dans un domaine, recalculer value depuis les booléens
const stats = src.stats ?? {}
for (const [statId, statData] of Object.entries(stats)) {
for (const [skillId, skill] of Object.entries(statData ?? {})) {
if (typeof skill !== "object" || !("level1" in skill)) continue
const value = [1,2,3,4,5,6,7,8].filter(i => skill[`level${i}`] === true).length
updateData[`system.stats.${statId}.${skillId}.value`] = value
}
}
// Factions : si level1 existe dans une faction, recalculer value depuis les booléens
const factions = src.factions ?? {}
for (const [factionId, faction] of Object.entries(factions)) {
if (typeof faction !== "object" || !("level1" in faction)) continue
const value = [1,2,3,4,5,6,7,8,9].filter(i => faction[`level${i}`] === true).length
updateData[`system.factions.${factionId}.value`] = value
}
}
if (Object.keys(updateData).length > 0) {
console.log(`${SYSTEM_ID} | Migration tracks → entiers : ${actor.name}`, updateData)
await actor.update(updateData)
}
}
}
/** Migration : ajoute les champs de biographie manquants sur les fiches existantes. */
async function _migrateBiographyFields() {
const actors = game.actors.contents.filter(actor => ["character", "npc"].includes(actor.type))
for (const actor of actors) {
const src = actor._source?.system
if (!src) continue
const updateData = {}
if (actor.type === "character" && !("historique" in src)) {
updateData["system.historique"] = ""
}
if (!("portraitImage" in src)) {
updateData["system.portraitImage"] = ""
}
if (Object.keys(updateData).length > 0) {
console.log(`${SYSTEM_ID} | Migration biographie : ${actor.name}`, updateData)
await actor.update(updateData)
}
}
}
/* ─── Handlebars helpers ─────────────────────────────────────────────────── */
function _registerHandlebarsHelpers() {
// Helper : concat strings
Handlebars.registerHelper("concat", (...args) => args.slice(0, -1).join(""))
// Helper : strict equality
Handlebars.registerHelper("eq", (a, b) => a === b)
// Helper : greater than
Handlebars.registerHelper("gt", (a, b) => a > b)
// Helper : less than or equal
Handlebars.registerHelper("lte", (a, b) => a <= b)
// Helper : greater than or equal
Handlebars.registerHelper("gte", (a, b) => a >= b)
// Helper : less than
Handlebars.registerHelper("lt", (a, b) => a < b)
// Helper : logical OR
Handlebars.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean))
// Helper : build array from args (Handlebars doesn't have arrays natively)
Handlebars.registerHelper("array", (...args) => args.slice(0, -1))
// Helper : range(n) → [1, 2, ..., n] — pour les boucles de cases à cocher
Handlebars.registerHelper("range", (n) => Array.from({ length: n }, (_, i) => i + 1))
// Helper : nested object lookup with dot path or multiple keys
Handlebars.registerHelper("lookup", (obj, ...args) => {
const options = args.pop() // last arg is Handlebars options hash
return args.reduce((acc, key) => (acc && acc[key] !== undefined ? acc[key] : undefined), obj)
})
// Helper : negate a number (abs value helper)
Handlebars.registerHelper("neg", n => -n)
// Helper : absolute value
Handlebars.registerHelper("abs", n => Math.abs(n))
// Helper : add two numbers
Handlebars.registerHelper("add", (a, b) => a + b)
// Helper : vrai si le dot lvl correspond au seuil de résistance de la spécialisation
Handlebars.registerHelper("isResThreshold", (skillId, lvl) => {
for (const group of Object.values(SYSTEM.SKILLS)) {
if (group[skillId]) return group[skillId].resThreshold === lvl
}
return false
})
Handlebars.registerHelper("let", function(value, options) {
return options.fn({ value })
})
}
/* ─── Migration depuis l'ancien système ─────────────────────────────────── */
/** Mapping des types d'anomalies ancien→nouveau */
const _OLD_ANOMALY_TYPES = [
"none", "entropie", "communicationaveclesmorts", "illusion",
"suggestion", "tarotdivinatoire", "telekinesie", "telepathie", "voyageastral",
]
const _OLD_SKILLS = {
ame: ["artifice", "attraction", "coercition", "faveur"],
corps: ["echauffouree", "effacement", "mobilite", "prouesse"],
coeur: ["appreciation", "arts", "inspiration", "traque"],
esprit: ["instruction", "mtechnologique", "raisonnement", "traitement"],
}
/** Infère le type d'anomalie depuis le nom de l'item (ex: "Entropie 1" → "entropie"). */
function _anomalyTypeFromName(name) {
let n = name.toLowerCase().replace(/\s*\d+\s*$/, "").replace(/\s+/g, "")
const accents = [["é","e"],["è","e"],["ê","e"],["ë","e"],["à","a"],["â","a"],
["î","i"],["ï","i"],["ô","o"],["û","u"],["ù","u"],["ü","u"],["ç","c"]]
for (const [s,d] of accents) n = n.replaceAll(s, d)
return _OLD_ANOMALY_TYPES.find(k => k !== "none" && (k.includes(n) || n.includes(k))) ?? "none"
}
/** Convertit les stats d'un PJ (skill → stats). */
function _convertStatsCharacter(oldSkill) {
const stats = {}
for (const stat of ["ame","corps","coeur","esprit"]) {
const os = oldSkill[stat] ?? {}
const ns = { label: stat, res: Number(os.res ?? 0) }
for (const sk of _OLD_SKILLS[stat]) {
const val = Number(os[sk]?.value ?? 0) || 0
ns[sk] = { label: sk, value: val }
}
stats[stat] = ns
}
return stats
}
/** Convertit les stats d'un PNJ (skill → stats avec actuel). */
function _convertStatsNPC(oldSkill) {
const stats = {}
for (const stat of ["ame","corps","coeur","esprit"]) {
const res = Number(oldSkill[stat]?.res ?? 0)
stats[stat] = { label: stat, res, actuel: res }
}
return stats
}
/** Convertit les attributs du format plat vers {value, max}. */
function _convertAttributs(a = {}) {
return {
entregent: { value: Number(a.entregent ?? 0), max: Number(a.entregentmax ?? 0) },
fortune: { value: Number(a.fortune ?? 0), max: Number(a.fortunemax ?? 0) },
reve: { value: Number(a.reve ?? 0), max: Number(a.revemax ?? 0) },
vision: { value: Number(a.vision ?? 0), max: Number(a.visionmax ?? 0) },
}
}
/** Convertit un item de l'ancien format. Retourne null si ignoré. */
function _convertItem(item, warnings) {
const os = item.system ?? {}
const name = item.name ?? "Sans nom"
const base = {
name,
img: item.img ?? "icons/svg/item-bag.svg",
effects: [],
flags: {},
sort: 0,
}
if (item.type === "anomaly") {
const level = Math.max(1, Math.min(4, Number(os.value ?? 1) || 1))
const subtype = _anomalyTypeFromName(name)
if (subtype === "none")
warnings.push(`Impossible de déterminer le type d'anomalie depuis "${name}"`)
return { ...base, type: "anomaly", system: {
subtype, level, usesRemaining: level,
technique: os.technique ?? "", narratif: os.narratif ?? "",
}}
}
if (item.type === "aspect") {
return { ...base, type: "aspect", system: {
valeur: Number(os.value ?? 0) || 0,
description: os.narratif ?? os.technique ?? "",
}}
}
if (item.type === "item") {
const sub = (os.subtype ?? "").toLowerCase()
if (sub === "weapon") {
if (os.damage) warnings.push(`"${name}" : dégâts "${os.damage}" à saisir manuellement`)
return { ...base, type: "weapon", system: {
type: "melee", degats: "0", portee: "contact", equipped: false,
description: os.technique ?? os.narratif ?? "",
}}
}
if (sub === "armor") {
if (os.protection) warnings.push(`"${name}" : protection "${os.protection}" à saisir manuellement`)
return { ...base, type: "armure", system: {
protection: 1, malus: 1, equipped: false,
description: os.technique ?? os.narratif ?? "",
}}
}
return { ...base, type: "equipment", system: {
description: os.technique ?? os.narratif ?? "",
}}
}
warnings.push(`Type d'item inconnu "${item.type}" pour "${name}", ignoré`)
return null
}
/** Convertit un acteur complet de l'ancien format vers le nouveau. */
function _convertOldActor(old) {
const warnings = []
const os = old.system ?? {}
const osk = os.skill ?? {}
const type = old.type ?? "character"
// Anomalie : résolution depuis l'index stocké dans os.anomaly
const anomalyIdx = Number(os.anomaly ?? 0)
const anomalyTypes = osk.anomalytypes ?? _OLD_ANOMALY_TYPES.map(k => `CEL1922.opt.${k}`)
const rawAnomalyKey = (anomalyTypes[anomalyIdx] ?? "").split(".").pop() ?? "none"
const anomalyType = _OLD_ANOMALY_TYPES.includes(rawAnomalyKey) ? rawAnomalyKey : "none"
const sys = {
concept: os.concept ?? "",
metier: os.metier ?? os.concept ?? "",
faction: os.faction ?? "",
blessures: { lvl: Number(os.blessures?.lvl ?? 0) },
destin: { lvl: Number(os.destin?.lvl ?? 0) },
spleen: { lvl: Number(os.spleen?.lvl ?? 0) },
}
if (type === "character") {
sys.stats = _convertStatsCharacter(osk)
sys.anomaly = { type: anomalyType, value: Number(os.anomalyval ?? 0) }
sys.attributs = _convertAttributs(os.attributs)
sys.historique = os.description ?? ""
sys.descriptionPhysique = ""
sys.descriptionPsychologique = ""
sys.initiative = 0
sys.factions = os.factions ?? {}
} else {
sys.stats = _convertStatsNPC(osk)
sys.npcType = os.npcType ?? "standard"
sys.historique = os.description ?? ""
}
const items = (old.items ?? []).map(it => _convertItem(it, warnings)).filter(Boolean)
const actor = {
name: old.name ?? "Personnage sans nom",
type,
img: old.img ?? "icons/svg/mystery-man.svg",
system: sys,
items,
effects: [],
folder: null,
flags: {},
prototypeToken: old.prototypeToken ?? {},
}
return { actor, warnings }
}
/** Ouvre la dialog DialogV2 de migration et gère l'import de l'acteur. */
async function _openMigrationDialog() {
const content = `
`
await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.localize("CELESTOPOL.Migration.title") },
classes: ["fvtt-celestopol"],
content,
buttons: [
{
label: game.i18n.localize("CELESTOPOL.Migration.importBtn"),
icon: "fas fa-file-import",
action: "import",
default: true,
callback: (_event, _btn, dialog) => {
const fileInput = dialog.element.querySelector("#cel-migration-file")
return fileInput?.files?.[0] ?? null
},
},
{
label: game.i18n.localize("Cancel"),
icon: "fas fa-times",
action: "cancel",
callback: () => null,
},
],
submit: async (result) => {
if (!result) return
const text = await result.text()
let old
try { old = JSON.parse(text) }
catch {
ui.notifications.error(game.i18n.localize("CELESTOPOL.Migration.errorParse"))
return
}
const { actor, warnings } = _convertOldActor(old)
try {
const created = await Actor.create(actor)
ui.notifications.info(game.i18n.format("CELESTOPOL.Migration.success", {
name: created.name, count: actor.items.length,
}))
if (warnings.length) {
console.warn(`Célestopol Migration | ${created.name} — avertissements :`)
warnings.forEach(w => console.warn(" ⚠", w))
ui.notifications.warn(game.i18n.format("CELESTOPOL.Migration.warnings", { count: warnings.length }))
}
} catch(err) {
console.error("Célestopol Migration | Échec de création de l'acteur :", err)
ui.notifications.error(game.i18n.localize("CELESTOPOL.Migration.errorCreate"))
}
},
})
}
/**
* Classe de menu de paramètre pour la migration.
* Étend ApplicationV2 (requis par registerMenu) mais surcharge render()
* pour déclencher directement la dialog de migration.
*/
class CelestopolMigrationMenu extends foundry.applications.api.ApplicationV2 {
static DEFAULT_OPTIONS = {
id: "celestopol-migration",
window: { title: "CELESTOPOL.Migration.title" },
classes: ["fvtt-celestopol"],
position: { width: 480 },
}
/** Surcharge : ignore le rendu AppV2 et ouvre directement la dialog. */
async render(...args) {
await _openMigrationDialog()
return this
}
}
/* ─── Settings ───────────────────────────────────────────────────────────── */
function _registerSettings() {
game.settings.register(SYSTEM_ID, "rollMoonDieByDefault", {
name: "CELESTOPOL.Setting.rollMoonDieByDefault.name",
hint: "CELESTOPOL.Setting.rollMoonDieByDefault.hint",
scope: "world",
config: true,
type: Boolean,
default: false,
})
// Suivi de l'import des anomalies (caché)
game.settings.register(SYSTEM_ID, "anomaliesImported", {
scope: "world",
config: false,
type: Boolean,
default: false,
})
game.settings.register(SYSTEM_ID, PREGENS_IMPORTED_SETTING, {
scope: "world",
config: false,
type: Boolean,
default: false,
})
game.settings.register(SYSTEM_ID, FACTION_ASPECT_STATE_SETTING, {
scope: "world",
config: false,
type: Object,
default: _getDefaultFactionAspectState(),
})
game.settings.register(SYSTEM_ID, WELCOME_SCENE_IMPORTED_SETTING, {
scope: "world",
config: false,
type: Boolean,
default: false,
})
// Entrée de menu : migration depuis l'ancien système
game.settings.registerMenu(SYSTEM_ID, "migrateOldSystem", {
name: "CELESTOPOL.Setting.migrateOldSystem.name",
hint: "CELESTOPOL.Setting.migrateOldSystem.hint",
label: "CELESTOPOL.Setting.migrateOldSystem.label",
icon: "fas fa-file-import",
type: CelestopolMigrationMenu,
restricted: true,
})
}
async function _createWelcomeChatMessage() {
const activeGM = game.users.activeGM
if (!game.user.isGM || (activeGM && activeGM.id !== game.user.id)) return
const helpPack = game.packs.get(`${SYSTEM_ID}.aides-de-jeu`)
const helpDocs = helpPack ? await helpPack.getDocuments() : []
const helpEntry = helpDocs[0] ?? null
const helpReference = helpEntry
? `@UUID[${helpEntry.uuid}]{${helpEntry.name}}`
: `**${helpPack?.metadata?.label ?? game.i18n.localize("CELESTOPOL.Welcome.helpFallback")}**`
const rawContent = `
${game.i18n.localize("CELESTOPOL.Welcome.intro")}
${game.i18n.localize("CELESTOPOL.Welcome.helpLabel")}
${game.i18n.format("CELESTOPOL.Welcome.helpCompendium", { help: helpReference })}
`
await ChatMessage.create({
style: CONST.CHAT_MESSAGE_STYLES.OOC,
speaker: { alias: game.system.title },
content: await foundry.applications.ux.TextEditor.implementation.enrichHTML(rawContent, { async: true }),
})
}
async function _setupWelcomeScene() {
const activeGM = game.users.activeGM
if (!game.user.isGM || (activeGM && activeGM.id !== game.user.id)) return
if (game.settings.get(SYSTEM_ID, WELCOME_SCENE_IMPORTED_SETTING)) return
const sceneName = "Accueil Celestopol 1922"
let scene = game.scenes.getName(sceneName)
if (!scene) {
const pack = game.packs.get(`${SYSTEM_ID}.scenes`)
if (!pack) {
console.warn(`${SYSTEM_ID} | Compendium de scènes introuvable`)
return
}
const docs = await pack.getDocuments()
const sourceScene = docs.find(doc => doc.name === sceneName)
if (!sourceScene) {
console.warn(`${SYSTEM_ID} | Scène d'accueil introuvable dans le compendium`)
return
}
scene = await Scene.create(sourceScene.toObject())
}
await scene.activate()
await scene.view()
await game.settings.set(SYSTEM_ID, WELCOME_SCENE_IMPORTED_SETTING, true)
}
async function _setupPregensFolder() {
const activeGM = game.users.activeGM
if (!game.user.isGM || (activeGM && activeGM.id !== game.user.id)) return
if (game.settings.get(SYSTEM_ID, PREGENS_IMPORTED_SETTING)) return
const pack = game.packs.get(`${SYSTEM_ID}.pretires`)
if (!pack) {
console.warn(`${SYSTEM_ID} | Compendium de prétirés introuvable`)
return
}
const folderName = game.i18n.localize("CELESTOPOL.Pregens.folderName")
let folder = game.folders.contents.find(f => f.type === "Actor" && f.name === folderName)
if (!folder) {
folder = await Folder.create({
name: folderName,
type: "Actor",
color: "#1b3828",
})
}
console.log(`${SYSTEM_ID} | Premier lancement : import des prétirés dans le monde`)
await pack.importAll({ folderId: folder.id, keepId: true })
await game.settings.set(SYSTEM_ID, PREGENS_IMPORTED_SETTING, true)
console.log(`${SYSTEM_ID} | Prétirés importés avec succès dans le dossier "${folder.name}"`)
ui.notifications.info(game.i18n.localize("CELESTOPOL.Pregens.imported"))
}
/* ─── Template preload ───────────────────────────────────────────────────── */
function _preloadTemplates() {
const base = `systems/${SYSTEM_ID}/templates`
foundry.applications.handlebars.loadTemplates([
`${base}/character-main.hbs`,
`${base}/character-competences.hbs`,
`${base}/character-blessures.hbs`,
`${base}/character-factions.hbs`,
`${base}/character-equipement.hbs`,
`${base}/character-biography.hbs`,
`${base}/npc-main.hbs`,
`${base}/npc-competences.hbs`,
`${base}/npc-blessures.hbs`,
`${base}/npc-equipement.hbs`,
`${base}/npc-biographie.hbs`,
`${base}/anomaly.hbs`,
`${base}/aspect.hbs`,
`${base}/equipment.hbs`,
`${base}/weapon.hbs`,
`${base}/armure.hbs`,
`${base}/roll-dialog.hbs`,
`${base}/chat-message.hbs`,
`${base}/moon-standalone.hbs`,
`${base}/partials/item-scores.hbs`,
])
}
/* ─── Socket handler ─────────────────────────────────────────────────────── */
async function _onSocketMessage(data) {
const activeGM = game.users.activeGM
if (!game.user.isGM || (activeGM && activeGM.id !== game.user.id)) return
switch (data.type) {
case "applyWound": {
const actor = game.actors.get(data.actorId)
if (actor) await actor.update({ "system.blessures.lvl": data.level })
break
}
case "applyWeaponDamage": {
await _applyWeaponDamage(data)
break
}
}
}
function _getChatHtmlRoot(html) {
if (html instanceof HTMLElement) return html
if (html?.[0] instanceof HTMLElement) return html[0]
if (html?.element instanceof HTMLElement) return html.element
if (html?.element?.[0] instanceof HTMLElement) return html.element[0]
return null
}
function _activateExistingChatCards() {
document.querySelectorAll(".message[data-message-id]").forEach(messageEl => {
const messageId = messageEl.dataset.messageId
const message = game.messages.get(messageId)
if (!message) return
const root = messageEl.querySelector(".celestopol.chat-roll, .celestopol-roll.moon-standalone-card")
if (!root) return
_activateChatCardListeners(message, root)
})
}
function _activateChatCardListeners(message, html) {
const root = _getChatHtmlRoot(html)
if (!root) return
_renderWeaponDamageState(message, root)
_renderMoonEffectState(message, root)
root.querySelectorAll('[data-action="apply-weapon-damage"]').forEach(button => {
if (button.dataset.bound === "true") return
button.dataset.bound = "true"
button.addEventListener("click", event => _onApplyWeaponDamageClick(event, message))
})
root.querySelectorAll('[data-action="apply-moon-effect"]').forEach(button => {
if (button.dataset.bound === "true") return
button.dataset.bound = "true"
button.addEventListener("click", event => _onApplyMoonEffectClick(event, message))
})
}
async function _onApplyWeaponDamageClick(event, message) {
event.preventDefault()
const button = event.currentTarget
const card = button.closest(".celestopol.chat-roll")
const select = button.closest(".weapon-damage-actions")?.querySelector('select[name="targetActorUuid"]')
const selectedOption = select?.selectedOptions?.[0] ?? null
const actorUuid = button.dataset.actorUuid || select?.value || ""
const actorId = button.dataset.actorId || selectedOption?.dataset.actorId || ""
const incomingWounds = Number.parseInt(button.dataset.incomingWounds ?? "", 10)
const currentState = _getDamageApplicationState(message)
if (currentState?.applied) {
if (card) _renderWeaponDamageState(message, card)
return
}
if (!actorUuid && !actorId) {
ui.notifications.warn(game.i18n.localize("CELESTOPOL.Combat.selectCharacterFirst"))
return
}
if (!Number.isFinite(incomingWounds) || incomingWounds < 0) return
if (card) _renderPendingWeaponDamageState(card)
button.disabled = true
await _requestWeaponDamageApplication({
actorId,
actorUuid,
incomingWounds,
chatMessageId: message?.id ?? null,
})
}
async function _requestWeaponDamageApplication({ actorId, actorUuid = null, incomingWounds, chatMessageId = null }) {
if (game.user.isGM) {
return _applyWeaponDamage({ actorId, actorUuid, incomingWounds, chatMessageId })
}
if (!game.socket) return
game.socket.emit(`system.${SYSTEM_ID}`, {
type: "applyWeaponDamage",
actorId,
actorUuid,
incomingWounds,
chatMessageId,
})
ui.notifications.info(game.i18n.localize("CELESTOPOL.Combat.damageRequestSent"))
}
function _getDamageApplicationState(message) {
return message?.getFlag(SYSTEM_ID, DAMAGE_APPLICATION_FLAG) ?? null
}
function _updateRenderedChatMessageState(message) {
const root = document.querySelector(`.message[data-message-id="${message.id}"] .celestopol.chat-roll`)
if (!root) return
_renderWeaponDamageState(message, root)
}
function _removeDamageStatus(root) {
root.querySelector(".damage-application-status")?.remove()
root.querySelector(".weapon-damage-summary")?.classList.remove("is-applied", "is-pending")
}
function _setDamageStatus(root, { text, cssClass = "" }) {
const summary = root.querySelector(".weapon-damage-summary")
if (!summary) return
_removeDamageStatus(root)
summary.classList.add(cssClass)
const status = document.createElement("div")
status.className = `damage-application-status ${cssClass}`.trim()
status.textContent = text
summary.append(status)
}
function _renderPendingWeaponDamageState(root) {
const button = root.querySelector('[data-action="apply-weapon-damage"]')
const select = root.querySelector('select[name="targetActorUuid"]')
if (button) {
button.disabled = true
button.textContent = game.i18n.localize("CELESTOPOL.Combat.damageApplying")
}
if (select) select.disabled = true
_setDamageStatus(root, {
text: game.i18n.localize("CELESTOPOL.Combat.damageApplyingNotice"),
cssClass: "is-pending",
})
}
function _renderWeaponDamageState(message, root) {
const button = root.querySelector('[data-action="apply-weapon-damage"]')
const select = root.querySelector('select[name="targetActorUuid"]')
const state = _getDamageApplicationState(message)
if (!state?.applied) {
if (button) button.textContent = game.i18n.localize("CELESTOPOL.Combat.applyDamage")
if (button) button.disabled = false
if (select) select.disabled = false
_removeDamageStatus(root)
return
}
if (button) {
button.disabled = true
button.textContent = game.i18n.localize("CELESTOPOL.Combat.damageAppliedDone")
}
if (select) select.disabled = true
const text = state.appliedWounds > 0
? game.i18n.format("CELESTOPOL.Combat.damageAppliedCard", {
actor: state.actorName,
wounds: state.appliedWounds,
armor: state.armorProtection,
})
: game.i18n.format("CELESTOPOL.Combat.damageNoEffectCard", {
actor: state.actorName,
armor: state.armorProtection,
})
_setDamageStatus(root, { text, cssClass: "is-applied" })
}
async function _markChatMessageDamageApplied(chatMessageId, data) {
if (!chatMessageId) return
const message = game.messages.get(chatMessageId)
if (!message) return
await message.setFlag(SYSTEM_ID, DAMAGE_APPLICATION_FLAG, {
applied: true,
...data,
})
_updateRenderedChatMessageState(message)
}
async function _applyWeaponDamage({ actorId, actorUuid = null, incomingWounds, chatMessageId = null }) {
const actor = await CelestopolRoll.resolveActor({ actorId, actorUuid })
if (!actor) return null
const message = chatMessageId ? game.messages.get(chatMessageId) : null
if (_getDamageApplicationState(message)?.applied) {
return null
}
const armorProtection = CelestopolRoll.getActorArmorProtection(actor)
const appliedWounds = Math.max(0, incomingWounds - armorProtection)
const currentWounds = actor.system?.blessures?.lvl ?? 0
const nextWounds = Math.min(8, currentWounds + appliedWounds)
if (appliedWounds > 0 && nextWounds !== currentWounds) {
await actor.update({ "system.blessures.lvl": nextWounds })
}
await _markChatMessageDamageApplied(chatMessageId, {
actorId,
actorName: actor.name,
appliedWounds,
armorProtection,
})
if (appliedWounds > 0) {
ui.notifications.info(game.i18n.format("CELESTOPOL.Combat.damageAppliedNotify", {
actor: actor.name,
wounds: appliedWounds,
armor: armorProtection,
}))
} else {
ui.notifications.info(game.i18n.format("CELESTOPOL.Combat.damageNoEffectNotify", {
actor: actor.name,
armor: armorProtection,
}))
}
return { actorName: actor.name, appliedWounds, armorProtection }
}
/* ─── Dé de la Lune — contreparties ─────────────────────────────────────── */
function _getMoonEffectState(message) {
return message?.getFlag(SYSTEM_ID, MOON_EFFECT_FLAG) ?? null
}
function _renderMoonEffectState(message, root) {
const state = _getMoonEffectState(message)
const actionsDiv = root.querySelector(".moon-effect-actions")
if (!actionsDiv) return
if (!state?.applied) return
const NEGATIVE_EFFECTS = new Set(["lose-anomaly", "gain-spleen", "lose-destin"])
// Désactiver tous les boutons et afficher le statut
actionsDiv.querySelectorAll(".moon-effect-btn").forEach(btn => { btn.disabled = true })
let statusEl = actionsDiv.querySelector(".moon-effect-applied-status")
if (!statusEl) {
statusEl = document.createElement("span")
actionsDiv.append(statusEl)
}
statusEl.className = "moon-effect-applied-status" + (NEGATIVE_EFFECTS.has(state.effect) ? " is-negative" : "")
statusEl.textContent = state.effectLabel
? game.i18n.format("CELESTOPOL.Moon.effectApplied", { label: state.effectLabel })
: game.i18n.localize("CELESTOPOL.Moon.effectApplied")
}
function _updateRenderedMoonEffectState(message) {
const msgEl = document.querySelector(`.message[data-message-id="${message.id}"]`)
if (!msgEl) return
const root = msgEl.querySelector(".celestopol.chat-roll, .celestopol-roll.moon-standalone-card")
if (!root) return
_renderMoonEffectState(message, root)
}
async function _onApplyMoonEffectClick(event, message) {
event.preventDefault()
const state = _getMoonEffectState(message)
if (state?.applied) return
const button = event.currentTarget
const effect = button.dataset.effect
const actionsDiv = button.closest(".moon-effect-actions")
const actorId = actionsDiv?.dataset.moonActorId ?? ""
const actorUuid = actionsDiv?.dataset.moonActorUuid ?? ""
// Désactiver immédiatement pour éviter les double-clics
actionsDiv?.querySelectorAll(".moon-effect-btn").forEach(btn => { btn.disabled = true })
const actor = await CelestopolRoll.resolveActor({ actorId, actorUuid })
if (!actor) {
ui.notifications.warn(game.i18n.localize("CELESTOPOL.Moon.actorNotFound"))
actionsDiv?.querySelectorAll(".moon-effect-btn").forEach(btn => { btn.disabled = false })
return
}
const effectLabel = await _applyMoonEffect(actor, effect)
if (effectLabel === null) {
actionsDiv?.querySelectorAll(".moon-effect-btn").forEach(btn => { btn.disabled = false })
return
}
await message.setFlag(SYSTEM_ID, MOON_EFFECT_FLAG, { applied: true, effect, effectLabel })
}
async function _applyMoonEffect(actor, effect) {
const i18n = key => game.i18n.localize(`CELESTOPOL.Moon.${key}`)
const anomaly = actor.items.find(i => i.type === "anomaly")
switch (effect) {
case "regain-anomaly": {
if (!anomaly) { ui.notifications.warn(i18n("noAnomaly")); return null }
const max = anomaly.system?.level ?? 0
const cur = anomaly.system?.usesRemaining ?? 0
if (cur >= max) { ui.notifications.warn(i18n("anomalyFull")); return null }
await anomaly.update({ "system.usesRemaining": Math.min(max, cur + 1) })
ui.notifications.info(game.i18n.format("CELESTOPOL.Moon.effectApplied", { label: i18n("effectRegainAnomaly") }))
return i18n("effectRegainAnomaly")
}
case "lose-spleen": {
const cur = actor.system?.spleen?.lvl ?? 0
if (cur <= 0) { ui.notifications.warn(i18n("spleenEmpty")); return null }
await actor.update({ "system.spleen.lvl": Math.max(0, cur - 1) })
ui.notifications.info(game.i18n.format("CELESTOPOL.Moon.effectApplied", { label: i18n("effectLoseSpleen") }))
return i18n("effectLoseSpleen")
}
case "gain-destin": {
const cur = actor.system?.destin?.lvl ?? 0
await actor.update({ "system.destin.lvl": Math.min(8, cur + 2) })
ui.notifications.info(game.i18n.format("CELESTOPOL.Moon.effectApplied", { label: i18n("effectGainDestin") }))
return i18n("effectGainDestin")
}
case "lose-destin": {
const cur = actor.system?.destin?.lvl ?? 0
await actor.update({ "system.destin.lvl": Math.max(0, cur - 2) })
ui.notifications.info(game.i18n.format("CELESTOPOL.Moon.effectApplied", { label: i18n("effectLoseDestin") }))
return i18n("effectLoseDestin")
}
case "lose-anomaly": {
if (!anomaly) { ui.notifications.warn(i18n("noAnomaly")); return null }
const cur = anomaly.system?.usesRemaining ?? 0
if (cur <= 0) { ui.notifications.warn(i18n("anomalyEmpty")); return null }
await anomaly.update({ "system.usesRemaining": Math.max(0, cur - 1) })
ui.notifications.info(game.i18n.format("CELESTOPOL.Moon.effectApplied", { label: i18n("effectLoseAnomaly") }))
return i18n("effectLoseAnomaly")
}
case "gain-spleen": {
const cur = actor.system?.spleen?.lvl ?? 0
if (cur >= 8) { ui.notifications.warn(i18n("spleenFull")); return null }
await actor.update({ "system.spleen.lvl": Math.min(8, cur + 1) })
ui.notifications.info(game.i18n.format("CELESTOPOL.Moon.effectApplied", { label: i18n("effectGainSpleen") }))
return i18n("effectGainSpleen")
}
default:
return null
}
}
function _getDefaultFactionAspectState() {
return {
pointsMax: 8,
activatedAspects: [],
customCell: {
enabled: false,
mode: "replace",
name: "",
aspectIds: [],
},
}
}
function _normalizeFactionId(value) {
const raw = `${value ?? ""}`.trim()
if (!raw) return ""
const direct = raw.toLowerCase()
if (SYSTEM.FACTIONS[direct]) return direct
for (const [id, faction] of Object.entries(SYSTEM.FACTIONS)) {
if (game.i18n.localize(faction.label).trim().toLowerCase() === direct) return id
}
return ""
}
function _getFactionDisplayLabel(value) {
const factionId = _normalizeFactionId(value)
if (!factionId) return `${value ?? ""}`.trim()
return game.i18n.localize(SYSTEM.FACTIONS[factionId].label)
}
function _sanitizeFactionAspectState(state = {}) {
const base = foundry.utils.mergeObject(_getDefaultFactionAspectState(), foundry.utils.deepClone(state), {
inplace: false,
insertKeys: true,
insertValues: true,
overwrite: true,
recursive: true,
})
base.pointsMax = Math.max(0, Number.parseInt(base.pointsMax ?? 8, 10) || 0)
base.customCell.enabled = Boolean(base.customCell?.enabled)
base.customCell.mode = base.customCell?.mode === "extend" ? "extend" : "replace"
base.customCell.name = `${base.customCell?.name ?? ""}`.trim()
base.customCell.aspectIds = Array.from(new Set((base.customCell?.aspectIds ?? [])
.filter(id => SYSTEM.FACTION_ASPECTS[id])))
base.activatedAspects = (base.activatedAspects ?? [])
.map(entry => {
const id = `${entry?.id ?? ""}`.trim()
const aspect = SYSTEM.FACTION_ASPECTS[id]
if (!aspect) return null
const value = Math.max(1, Math.min(4, Number.parseInt(entry.value ?? 1, 10) || 1))
return {
id,
value,
label: game.i18n.localize(aspect.label),
}
})
.filter(Boolean)
return base
}
function _getFactionAspectState() {
const stored = game.settings.get(SYSTEM_ID, FACTION_ASPECT_STATE_SETTING) ?? {}
return _sanitizeFactionAspectState(stored)
}
async function _setFactionAspectState(state) {
const cleanState = _sanitizeFactionAspectState(state)
await game.settings.set(SYSTEM_ID, FACTION_ASPECT_STATE_SETTING, cleanState)
_refreshFactionAspectSheets()
return cleanState
}
async function _resetFactionAspects() {
if (!game.user.isGM) {
ui.notifications.warn(game.i18n.localize("CELESTOPOL.FactionAspect.gmOnly"))
return
}
const confirmed = await foundry.applications.api.DialogV2.confirm({
window: { title: game.i18n.localize("CELESTOPOL.FactionAspect.resetTitle") },
content: `${game.i18n.localize("CELESTOPOL.FactionAspect.resetConfirm")}
`,
yes: { label: game.i18n.localize("CELESTOPOL.FactionAspect.resetConfirmYes"), icon: "fas fa-redo" },
no: { label: game.i18n.localize("Cancel"), icon: "fas fa-times" },
rejectClose: false,
})
if (!confirmed) return
const state = _getFactionAspectState()
await _setFactionAspectState({ ...state, activatedAspects: [] })
ui.notifications.info(game.i18n.localize("CELESTOPOL.FactionAspect.resetSuccess"))
}
function _refreshFactionAspectSheets() {
for (const actor of game.actors.contents) {
if (actor.type !== "character") continue
if (!actor.sheet?.rendered) continue
actor.sheet.render(true)
}
}
function _getRepresentedFactionIds() {
return Array.from(new Set(
game.actors.contents
.filter(actor => actor.type === "character")
.map(actor => _normalizeFactionId(actor.system?.faction))
.filter(Boolean)
))
}
function _getFactionAspectSourceData(state = _getFactionAspectState()) {
const representedFactionIds = _getRepresentedFactionIds()
const sourceFactionIds = state.customCell.enabled && state.customCell.mode === "replace"
? []
: representedFactionIds
const sourceAspectIds = new Set()
const officialSourceLabels = []
const sourceLabels = []
for (const factionId of sourceFactionIds) {
const label = game.i18n.localize(SYSTEM.FACTIONS[factionId].label)
officialSourceLabels.push(label)
sourceLabels.push(label)
for (const aspectId of SYSTEM.FACTION_ASPECTS_BY_FACTION[factionId] ?? []) {
sourceAspectIds.add(aspectId)
}
}
let customCellLabel = ""
if (state.customCell.enabled) {
customCellLabel = state.customCell.name || game.i18n.localize("CELESTOPOL.FactionAspect.customCell")
sourceLabels.push(customCellLabel)
for (const aspectId of state.customCell.aspectIds ?? []) {
sourceAspectIds.add(aspectId)
}
}
return {
representedFactionIds,
officialSourceLabels,
availableAspectIds: Array.from(sourceAspectIds),
sourceLabels,
customCellLabel,
}
}
function _getActorAvailableFactionAspectIds(actor, state = _getFactionAspectState()) {
const ids = new Set()
const factionId = _normalizeFactionId(actor?.system?.faction)
if (state.customCell.enabled && state.customCell.mode === "replace") {
for (const aspectId of state.customCell.aspectIds ?? []) ids.add(aspectId)
return ids
}
if (factionId) {
for (const aspectId of SYSTEM.FACTION_ASPECTS_BY_FACTION[factionId] ?? []) ids.add(aspectId)
}
if (state.customCell.enabled && state.customCell.mode === "extend") {
for (const aspectId of state.customCell.aspectIds ?? []) ids.add(aspectId)
}
return ids
}
function _getFactionAspectSummary(actor = null) {
const state = _getFactionAspectState()
const sourceData = _getFactionAspectSourceData(state)
const primaryFactionId = actor ? _normalizeFactionId(actor.system?.faction) : ""
const actorAvailableIds = actor ? _getActorAvailableFactionAspectIds(actor, state) : new Set(sourceData.availableAspectIds)
const pointsSpent = state.activatedAspects.reduce((sum, aspect) => sum + aspect.value, 0)
const pointsRemaining = Math.max(0, state.pointsMax - pointsSpent)
return {
...state,
pointsSpent,
pointsRemaining,
sourceLabels: sourceData.sourceLabels,
officialSourceLabels: sourceData.officialSourceLabels,
customCellLabel: sourceData.customCellLabel,
hasOfficialSources: sourceData.officialSourceLabels.length > 0,
needsSourceConfiguration: !sourceData.officialSourceLabels.length && !state.customCell.enabled,
representedFactions: sourceData.representedFactionIds.map(id => ({
id,
label: game.i18n.localize(SYSTEM.FACTIONS[id].label),
})),
primaryFactionId,
primaryFactionLabel: primaryFactionId ? game.i18n.localize(SYSTEM.FACTIONS[primaryFactionId].label) : _getFactionDisplayLabel(actor?.system?.faction),
availableAspectChoices: state.activatedAspects.map(aspect => ({
id: aspect.id,
value: aspect.value,
label: aspect.label,
})),
activatableAspectChoices: sourceData.availableAspectIds
.filter(id => !state.activatedAspects.some(aspect => aspect.id === id))
.map(id => ({
id,
label: game.i18n.localize(SYSTEM.FACTION_ASPECTS[id].label),
})),
availableAspectLabels: sourceData.availableAspectIds.map(id => game.i18n.localize(SYSTEM.FACTION_ASPECTS[id].label)),
activatedAspects: state.activatedAspects.map(aspect => ({
...aspect,
relevantToActor: actor ? actorAvailableIds.has(aspect.id) : true,
})),
}
}
function _parseFactionAspectManagerForm(form) {
return {
pointsMax: Math.max(0, Number.parseInt(form.querySelector('[name="pointsMax"]')?.value ?? 8, 10) || 0),
customCellEnabled: form.querySelector('[name="customCellEnabled"]')?.checked ?? false,
customCellMode: form.querySelector('[name="customCellMode"]')?.value === "extend" ? "extend" : "replace",
customCellName: `${form.querySelector('[name="customCellName"]')?.value ?? ""}`.trim(),
customCellAspectIds: Array.from(form.querySelectorAll('input[name="customCellAspectIds"]:checked')).map(input => input.value),
activateAspectId: `${form.querySelector('[name="activateAspectId"]')?.value ?? ""}`.trim(),
activateAspectValue: Math.max(1, Math.min(4, Number.parseInt(form.querySelector('[name="activateAspectValue"]')?.value ?? 1, 10) || 1)),
removeAspectId: `${form.querySelector('[name="removeAspectId"]')?.value ?? ""}`.trim(),
}
}
function _renderFactionAspectManagerContent(summary) {
const i18n = game.i18n
const hint = (text) => ` ?`
const checkedAspectIds = new Set(summary.customCell.aspectIds ?? [])
const customAspectCheckboxes = Object.values(SYSTEM.FACTION_ASPECTS).map(aspect => `
`).join("")
const activatableOptions = summary.activatableAspectChoices.length
? summary.activatableAspectChoices.map(aspect => ``).join("")
: ``
const activatedRows = summary.activatedAspects.length
? summary.activatedAspects.map(aspect => `
${aspect.label}
+${aspect.value}
`).join("")
: `${i18n.localize("CELESTOPOL.FactionAspect.noneActive")}
`
const sourceLabels = summary.sourceLabels.length
? summary.sourceLabels.join(" • ")
: i18n.localize("CELESTOPOL.FactionAspect.noSource")
const availableAspectList = summary.availableAspectLabels.length
? summary.availableAspectLabels.map(label => `${label}`).join("")
: `${i18n.localize("CELESTOPOL.FactionAspect.noAspectAvailable")}
`
const removeOptions = summary.activatedAspects.length
? summary.activatedAspects.map(aspect => ``).join("")
: ``
const officialSourcesBlock = summary.hasOfficialSources
? `
${summary.officialSourceLabels.map(label => `${label}`).join("")}
`
: `${i18n.localize("CELESTOPOL.FactionAspect.officialSourcesEmpty")}
`
const customCellOpen = summary.customCell.enabled ? "open" : ""
return `
`
}
async function _saveFactionAspectManagerSettings(formData, currentState) {
if (!game.user.isGM) {
formData.pointsMax = currentState.pointsMax
}
const activatedCost = currentState.activatedAspects.reduce((sum, aspect) => sum + aspect.value, 0)
if (formData.pointsMax < activatedCost) {
ui.notifications.warn(game.i18n.localize("CELESTOPOL.FactionAspect.pointsBelowSpent"))
return null
}
if (formData.customCellEnabled) {
const count = formData.customCellAspectIds.length
if (count < 4 || count > 8) {
ui.notifications.warn(game.i18n.localize("CELESTOPOL.FactionAspect.customCellAspectCount"))
return null
}
}
return _setFactionAspectState({
...currentState,
pointsMax: formData.pointsMax,
customCell: {
enabled: formData.customCellEnabled,
mode: formData.customCellMode,
name: formData.customCellName,
aspectIds: formData.customCellAspectIds,
},
})
}
async function _manageFactionAspects(actor = null) {
const summary = _getFactionAspectSummary(actor)
const result = await foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.localize("CELESTOPOL.FactionAspect.managerTitle") },
classes: ["fvtt-celestopol", "faction-aspect-dialog"],
content: _renderFactionAspectManagerContent(summary),
buttons: [
{
action: "save",
label: game.i18n.localize("CELESTOPOL.FactionAspect.save"),
callback: (_event, button) => ({ action: "save", ..._parseFactionAspectManagerForm(button.form) }),
},
{
action: "activate",
label: game.i18n.localize("CELESTOPOL.FactionAspect.activateButton"),
callback: (_event, button) => ({ action: "activate", ..._parseFactionAspectManagerForm(button.form) }),
},
{
action: "remove",
label: game.i18n.localize("CELESTOPOL.FactionAspect.removeButton"),
callback: (_event, button) => ({ action: "remove", ..._parseFactionAspectManagerForm(button.form) }),
},
{
action: "reset",
label: game.i18n.localize("CELESTOPOL.FactionAspect.resetScenario"),
callback: () => ({ action: "reset" }),
},
],
rejectClose: false,
})
if (!result?.action) return null
if (result.action === "save") {
const updatedState = await _saveFactionAspectManagerSettings(result, _getFactionAspectState())
if (updatedState) ui.notifications.info(game.i18n.localize("CELESTOPOL.FactionAspect.saved"))
return null
}
if (result.action === "reset") {
const currentState = _getFactionAspectState()
await _setFactionAspectState({ ...currentState, activatedAspects: [] })
ui.notifications.info(game.i18n.localize("CELESTOPOL.FactionAspect.resetDone"))
return null
}
if (result.action === "activate") {
const savedState = await _saveFactionAspectManagerSettings(result, _getFactionAspectState())
if (!savedState) return null
const aspectId = result.activateAspectId
const aspectValue = result.activateAspectValue
const availableIds = new Set(_getFactionAspectSourceData(savedState).availableAspectIds)
const alreadyActive = savedState.activatedAspects.some(aspect => aspect.id === aspectId)
const currentSpent = savedState.activatedAspects.reduce((sum, aspect) => sum + aspect.value, 0)
if (!aspectId || !SYSTEM.FACTION_ASPECTS[aspectId] || !availableIds.has(aspectId)) {
ui.notifications.warn(game.i18n.localize("CELESTOPOL.FactionAspect.invalidAspect"))
return null
}
if (alreadyActive) {
ui.notifications.warn(game.i18n.localize("CELESTOPOL.FactionAspect.alreadyActive"))
return null
}
if ((currentSpent + aspectValue) > savedState.pointsMax) {
ui.notifications.warn(game.i18n.localize("CELESTOPOL.FactionAspect.notEnoughPoints"))
return null
}
const aspectLabel = game.i18n.localize(SYSTEM.FACTION_ASPECTS[aspectId].label)
await _setFactionAspectState({
...savedState,
activatedAspects: [
...savedState.activatedAspects,
{ id: aspectId, value: aspectValue, label: aspectLabel },
],
})
ui.notifications.info(game.i18n.format("CELESTOPOL.FactionAspect.activated", {
aspect: aspectLabel,
value: aspectValue,
}))
}
if (result.action === "remove") {
const savedState = await _saveFactionAspectManagerSettings(result, _getFactionAspectState())
if (!savedState) return null
const aspectId = result.removeAspectId
const removedAspect = savedState.activatedAspects.find(aspect => aspect.id === aspectId)
if (!removedAspect) {
ui.notifications.warn(game.i18n.localize("CELESTOPOL.FactionAspect.invalidRemove"))
return null
}
await _setFactionAspectState({
...savedState,
activatedAspects: savedState.activatedAspects.filter(aspect => aspect.id !== aspectId),
})
ui.notifications.info(game.i18n.format("CELESTOPOL.FactionAspect.removed", {
aspect: removedAspect.label,
value: removedAspect.value,
}))
}
return null
}
/* ─── Import initial des anomalies du compendium dans le monde ─────────── */
/**
* Au premier lancement (GM uniquement), crée le dossier « Anomalies » dans
* les Items du monde et y importe tous les items du compendium anomalies.
*/
async function _setupAnomaliesFolder() {
if (game.settings.get(SYSTEM_ID, "anomaliesImported")) return
const pack = game.packs.get(`${SYSTEM_ID}.anomalies`)
if (!pack) {
console.warn(`${SYSTEM_ID} | Compendium anomalies introuvable`)
return
}
console.log(`${SYSTEM_ID} | Premier lancement : import des anomalies dans le monde`)
// Créer le dossier « Anomalies » dans les Items
const folder = await Folder.create({
name: "Anomalies",
type: "Item",
color: "#1b3828",
})
// Importer tous les items du compendium dans ce dossier
await pack.importAll({ folderId: folder.id, keepId: true })
await game.settings.set(SYSTEM_ID, "anomaliesImported", true)
console.log(`${SYSTEM_ID} | Anomalies importées avec succès dans le dossier "${folder.name}"`)
ui.notifications.info("Célestopol 1922 | Anomalies importées dans le dossier Items.")
}
/* ─── Nom par défaut des items à la création ─────────────────────────────── */
Hooks.on("preCreateItem", (item, data) => {
const defaultNames = {
weapon: () => game.i18n.localize("TYPES.Item.weapon"),
armure: () => game.i18n.localize("TYPES.Item.armure"),
anomaly: () => game.i18n.localize("TYPES.Item.anomaly"),
aspect: () => game.i18n.localize("TYPES.Item.aspect"),
equipment: () => game.i18n.localize("TYPES.Item.equipment"),
}
const defaultIcons = {
weapon: "systems/fvtt-celestopol/assets/icons/weapon.svg",
armure: "systems/fvtt-celestopol/assets/icons/armure.svg",
anomaly: "systems/fvtt-celestopol/assets/icons/anomaly.svg",
aspect: "systems/fvtt-celestopol/assets/icons/aspect.svg",
equipment: "systems/fvtt-celestopol/assets/icons/equipment.svg",
}
const updates = {}
const fn = defaultNames[item.type]
if (fn && (!data.name || data.name === "New Item" || data.name === item.type)) {
updates.name = fn()
}
const defaultIcon = defaultIcons[item.type]
if (defaultIcon && (!data.img || data.img === "icons/svg/item-bag.svg")) {
updates.img = defaultIcon
}
if (Object.keys(updates).length) item.updateSource(updates)
})