Files
fvtt-celestopol/fvtt-celestopol.mjs
LeRatierBretonnier 7a2be0cc0e
Some checks failed
Release Creation / build (release) Failing after 1m41s
Update compendium and welcom chat message
2026-04-11 15:29:44 +02:00

1217 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Célestopol 1922 — Système FoundryVTT
*
* Célestopol 1922 est un jeu de rôle édité par Antre-Monde Éditions.
* Ce système FoundryVTT est une implémentation indépendante et n'est pas
* affilié à Antre-Monde Éditions,
* mais a été réalisé avec l'autorisation d'Antre-Monde Éditions.
*
* @author LeRatierBretonnien
* @copyright 20252026 LeRatierBretonnien
* @license CC BY-NC-SA 4.0 https://creativecommons.org/licenses/by-nc-sa/4.0/
*/
import { SYSTEM, 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"
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),
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)
}
})
_activateExistingChatCards()
// Migration : supprime les items de types obsolètes (ex: "attribute")
if (game.user.isGM) {
_migrateObsoleteItems()
_migrateIntegerTracks()
_setupAnomaliesFolder()
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)
}
}
}
/* ─── 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(),
})
game.settings.register(SYSTEM_ID, WELCOME_SCENE_IMPORTED_SETTING, {
scope: "world",
config: false,
type: Boolean,
default: false,
})
}
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 = `
<div class="cel-welcome-message chat-system-card">
<div class="welcome-header">
<span class="welcome-mark">✦</span>
<span class="welcome-title">${game.i18n.localize("CELESTOPOL.Welcome.title")}</span>
</div>
<div class="welcome-body">
<p>${game.i18n.localize("CELESTOPOL.Welcome.intro")}</p>
<div class="welcome-note">
<span class="welcome-label">${game.i18n.localize("CELESTOPOL.Welcome.helpLabel")}</span>
<span class="welcome-value">${game.i18n.format("CELESTOPOL.Welcome.helpCompendium", { help: helpReference })}</span>
</div>
<div class="welcome-note">
<span class="welcome-label">${game.i18n.localize("CELESTOPOL.Welcome.bookLabel")}</span>
<span class="welcome-value"><a href="https://antre-monde.com/produit/celestopol-1922-le-jeu-de-role-livre-de-base/" target="_blank" rel="noopener noreferrer">${game.i18n.localize("CELESTOPOL.Welcome.bookLinkLabel")}</a></span>
</div>
</div>
</div>
`
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)
}
/* ─── 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) => ` <span class="faction-aspect-help-tip" title="${foundry.utils.escapeHTML(text)}">?</span>`
const checkedAspectIds = new Set(summary.customCell.aspectIds ?? [])
const customAspectCheckboxes = Object.values(SYSTEM.FACTION_ASPECTS).map(aspect => `
<label class="faction-aspect-cell-option">
<input type="checkbox" name="customCellAspectIds" value="${aspect.id}" ${checkedAspectIds.has(aspect.id) ? "checked" : ""}>
<span>${i18n.localize(aspect.label)}</span>
</label>
`).join("")
const activatableOptions = summary.activatableAspectChoices.length
? summary.activatableAspectChoices.map(aspect => `<option value="${aspect.id}">${aspect.label}</option>`).join("")
: `<option value="">${i18n.localize("CELESTOPOL.FactionAspect.noAspectAvailable")}</option>`
const activatedRows = summary.activatedAspects.length
? summary.activatedAspects.map(aspect => `
<div class="faction-aspect-active-row ${aspect.relevantToActor ? "is-relevant" : ""}">
<span class="faction-aspect-active-name">${aspect.label}</span>
<span class="faction-aspect-active-value">+${aspect.value}</span>
</div>
`).join("")
: `<div class="faction-aspect-empty">${i18n.localize("CELESTOPOL.FactionAspect.noneActive")}</div>`
const sourceLabels = summary.sourceLabels.length
? summary.sourceLabels.join(" • ")
: i18n.localize("CELESTOPOL.FactionAspect.noSource")
const availableAspectList = summary.availableAspectLabels.length
? summary.availableAspectLabels.map(label => `<span class="faction-aspect-tag">${label}</span>`).join("")
: `<div class="faction-aspect-empty">${i18n.localize("CELESTOPOL.FactionAspect.noAspectAvailable")}</div>`
const removeOptions = summary.activatedAspects.length
? summary.activatedAspects.map(aspect => `<option value="${aspect.id}">${aspect.label} (+${aspect.value})</option>`).join("")
: `<option value="">${i18n.localize("CELESTOPOL.FactionAspect.noneActive")}</option>`
const officialSourcesBlock = summary.hasOfficialSources
? `
<div class="faction-aspect-source-list">
${summary.officialSourceLabels.map(label => `<span class="faction-aspect-tag">${label}</span>`).join("")}
</div>
`
: `<div class="faction-aspect-warning">${i18n.localize("CELESTOPOL.FactionAspect.officialSourcesEmpty")}</div>`
const customCellOpen = summary.customCell.enabled ? "open" : ""
return `
<form class="cel-dialog-form faction-aspect-manager">
<div class="faction-aspect-box">
<div class="faction-aspect-box-title">${i18n.localize("CELESTOPOL.FactionAspect.managerState")}</div>
<div class="faction-aspect-points">
<span class="faction-aspect-point-card"><strong>${i18n.localize("CELESTOPOL.FactionAspect.pointsMax")}</strong><em>${summary.pointsMax}</em></span>
<span class="faction-aspect-point-card"><strong>${i18n.localize("CELESTOPOL.FactionAspect.pointsSpent")}</strong><em>${summary.pointsSpent}</em></span>
<span class="faction-aspect-point-card"><strong>${i18n.localize("CELESTOPOL.FactionAspect.pointsRemaining")}</strong><em>${summary.pointsRemaining}</em></span>
</div>
${game.user.isGM ? `
<div class="form-group faction-aspect-pool-group">
<label>${i18n.localize("CELESTOPOL.FactionAspect.globalPoolLabel")}${hint(i18n.localize("CELESTOPOL.FactionAspect.globalPoolHint"))}</label>
<input type="number" name="pointsMax" min="0" value="${summary.pointsMax}">
</div>
` : ""}
<div class="faction-aspect-source-line"><strong>${i18n.localize("CELESTOPOL.FactionAspect.sources")}</strong> ${sourceLabels}</div>
</div>
<div class="faction-aspect-box">
<div class="faction-aspect-box-title">${i18n.localize("CELESTOPOL.FactionAspect.officialSourcesTitle")}${hint(i18n.localize("CELESTOPOL.FactionAspect.officialSourcesHint"))}</div>
${officialSourcesBlock}
</div>
<div class="faction-aspect-box">
<div class="faction-aspect-box-title">${i18n.localize("CELESTOPOL.FactionAspect.availablePoolTitle")}${hint(i18n.localize("CELESTOPOL.FactionAspect.availablePoolHint"))}</div>
<div class="faction-aspect-tag-list">${availableAspectList}</div>
</div>
<div class="faction-aspect-box">
<div class="faction-aspect-box-title">${i18n.localize("CELESTOPOL.FactionAspect.managerSettings")}</div>
<details class="faction-aspect-advanced" ${customCellOpen}>
<summary>${i18n.localize("CELESTOPOL.FactionAspect.customCellSection")}${hint(i18n.localize("CELESTOPOL.FactionAspect.customCellHint"))}</summary>
<div class="form-group faction-aspect-checkbox-line">
<label>
<input type="checkbox" name="customCellEnabled" ${summary.customCell.enabled ? "checked" : ""}>
${i18n.localize("CELESTOPOL.FactionAspect.customCellEnabled")}
</label>
</div>
<div class="form-group">
<label>${i18n.localize("CELESTOPOL.FactionAspect.customCellName")}</label>
<input type="text" name="customCellName" value="${foundry.utils.escapeHTML(summary.customCell.name ?? "")}" placeholder="${i18n.localize("CELESTOPOL.FactionAspect.customCell")}">
</div>
<div class="form-group">
<label>${i18n.localize("CELESTOPOL.FactionAspect.customCellMode")}</label>
<select name="customCellMode">
<option value="replace" ${summary.customCell.mode === "replace" ? "selected" : ""}>${i18n.localize("CELESTOPOL.FactionAspect.modeReplace")}</option>
<option value="extend" ${summary.customCell.mode === "extend" ? "selected" : ""}>${i18n.localize("CELESTOPOL.FactionAspect.modeExtend")}</option>
</select>
</div>
<div class="faction-aspect-cell-grid">${customAspectCheckboxes}</div>
</details>
</div>
<div class="faction-aspect-box">
<div class="faction-aspect-box-title">${i18n.localize("CELESTOPOL.FactionAspect.activateTitle")}${hint(i18n.localize("CELESTOPOL.FactionAspect.activateHint"))}</div>
<div class="form-group">
<label>${i18n.localize("CELESTOPOL.FactionAspect.activateAspect")}</label>
<select name="activateAspectId">${activatableOptions}</select>
</div>
<div class="form-group">
<label>${i18n.localize("CELESTOPOL.FactionAspect.activateValue")}</label>
<select name="activateAspectValue">
<option value="1">+1</option>
<option value="2">+2</option>
<option value="3">+3</option>
<option value="4">+4</option>
</select>
</div>
</div>
<div class="faction-aspect-box">
<div class="faction-aspect-box-title">${i18n.localize("CELESTOPOL.FactionAspect.activeTitle")}</div>
<div class="faction-aspect-active-list">${activatedRows}</div>
<div class="faction-aspect-remove-block">
<div class="form-group">
<label>${i18n.localize("CELESTOPOL.FactionAspect.removeAspect")}</label>
<select name="removeAspectId">${removeOptions}</select>
</div>
</div>
</div>
</form>
`
}
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)
})