1140 lines
44 KiB
JavaScript
1140 lines
44 KiB
JavaScript
/**
|
||
* Célestopol 1922 — Système FoundryVTT
|
||
*
|
||
* Célestopol 1922 est un jeu de rôle édité par Antre-Monde Éditions.
|
||
* Ce système FoundryVTT est une implémentation indépendante et n'est pas
|
||
* affilié à Antre-Monde Éditions,
|
||
* mais a été réalisé avec l'autorisation d'Antre-Monde Éditions.
|
||
*
|
||
* @author LeRatierBretonnien
|
||
* @copyright 2025–2026 LeRatierBretonnien
|
||
* @license CC BY-NC-SA 4.0 – https://creativecommons.org/licenses/by-nc-sa/4.0/
|
||
*/
|
||
|
||
import { SYSTEM, 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) => ` <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)
|
||
})
|