/** * 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 FACTION_ASPECT_STATE_SETTING = "factionAspectState" /* ─── 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), 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", () => { 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) } }) _activateExistingChatCards() // Migration : supprime les items de types obsolètes (ex: "attribute") if (game.user.isGM) { _migrateObsoleteItems() _migrateIntegerTracks() _setupAnomaliesFolder() } }) /** 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) } } } /* ─── 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 }) }) } /* ─── 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, FACTION_ASPECT_STATE_SETTING, { scope: "world", config: false, type: Object, default: _getDefaultFactionAspectState(), }) } /* ─── 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) const root = messageEl.querySelector(".celestopol.chat-roll") if (!message || !root) return _activateChatCardListeners(message, root) }) } function _activateChatCardListeners(message, html) { const root = _getChatHtmlRoot(html) if (!root) return _renderWeaponDamageState(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)) }) } 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="targetActorId"]') const actorId = button.dataset.actorId || select?.value || "" const incomingWounds = Number.parseInt(button.dataset.incomingWounds ?? "", 10) const currentState = _getDamageApplicationState(message) if (currentState?.applied) { if (card) _renderWeaponDamageState(message, card) return } if (!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, incomingWounds, chatMessageId: message?.id ?? null, }) } async function _requestWeaponDamageApplication({ actorId, incomingWounds, chatMessageId = null }) { if (game.user.isGM) { return _applyWeaponDamage({ actorId, incomingWounds, chatMessageId }) } if (!game.socket) return game.socket.emit(`system.${SYSTEM_ID}`, { type: "applyWeaponDamage", actorId, 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="targetActorId"]') 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="targetActorId"]') 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, incomingWounds, chatMessageId = null }) { const actor = game.actors.get(actorId) 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 } } 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 } 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 `
${i18n.localize("CELESTOPOL.FactionAspect.managerState")}
${i18n.localize("CELESTOPOL.FactionAspect.pointsMax")}${summary.pointsMax} ${i18n.localize("CELESTOPOL.FactionAspect.pointsSpent")}${summary.pointsSpent} ${i18n.localize("CELESTOPOL.FactionAspect.pointsRemaining")}${summary.pointsRemaining}
${game.user.isGM ? `
` : ""}
${i18n.localize("CELESTOPOL.FactionAspect.sources")} ${sourceLabels}
${i18n.localize("CELESTOPOL.FactionAspect.officialSourcesTitle")}${hint(i18n.localize("CELESTOPOL.FactionAspect.officialSourcesHint"))}
${officialSourcesBlock}
${i18n.localize("CELESTOPOL.FactionAspect.availablePoolTitle")}${hint(i18n.localize("CELESTOPOL.FactionAspect.availablePoolHint"))}
${availableAspectList}
${i18n.localize("CELESTOPOL.FactionAspect.managerSettings")}
${i18n.localize("CELESTOPOL.FactionAspect.customCellSection")}${hint(i18n.localize("CELESTOPOL.FactionAspect.customCellHint"))}
${customAspectCheckboxes}
${i18n.localize("CELESTOPOL.FactionAspect.activateTitle")}${hint(i18n.localize("CELESTOPOL.FactionAspect.activateHint"))}
${i18n.localize("CELESTOPOL.FactionAspect.activeTitle")}
${activatedRows}
` } 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) })