diff --git a/assets/ui/help/aide-aspects-dialogue.png b/assets/ui/help/aide-aspects-dialogue.png
new file mode 100644
index 0000000..73a5a91
Binary files /dev/null and b/assets/ui/help/aide-aspects-dialogue.png differ
diff --git a/assets/ui/help/aide-combat-chat.png b/assets/ui/help/aide-combat-chat.png
new file mode 100644
index 0000000..cbe112b
Binary files /dev/null and b/assets/ui/help/aide-combat-chat.png differ
diff --git a/assets/ui/help/aide-domaines.png b/assets/ui/help/aide-domaines.png
new file mode 100644
index 0000000..52f60f4
Binary files /dev/null and b/assets/ui/help/aide-domaines.png differ
diff --git a/assets/ui/help/aide-factions.png b/assets/ui/help/aide-factions.png
new file mode 100644
index 0000000..c043be8
Binary files /dev/null and b/assets/ui/help/aide-factions.png differ
diff --git a/assets/ui/help/aide-jet.png b/assets/ui/help/aide-jet.png
new file mode 100644
index 0000000..d9c4520
Binary files /dev/null and b/assets/ui/help/aide-jet.png differ
diff --git a/fvtt-celestopol.mjs b/fvtt-celestopol.mjs
index ef81a0e..9df2ac3 100644
--- a/fvtt-celestopol.mjs
+++ b/fvtt-celestopol.mjs
@@ -1,6 +1,14 @@
/**
- * fvtt-celestopol.mjs — Point d'entrée principal du système Célestopol 1922
- * FoundryVTT v13+ / DataModels / ApplicationV2
+ * 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"
@@ -30,6 +38,9 @@ import {
CelestopolArmureSheet,
} from "./module/applications/_module.mjs"
+const DAMAGE_APPLICATION_FLAG = "damageApplication"
+const FACTION_ASPECT_STATE_SETTING = "factionAspectState"
+
/* ─── Init hook ──────────────────────────────────────────────────────────── */
Hooks.once("init", () => {
@@ -52,6 +63,11 @@ Hooks.once("init", () => {
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 ──────────────────────────────────────────────────────────
@@ -148,6 +164,16 @@ Hooks.once("ready", () => {
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()
@@ -317,6 +343,12 @@ function _registerSettings() {
type: Boolean,
default: false,
})
+ game.settings.register(SYSTEM_ID, FACTION_ASPECT_STATE_SETTING, {
+ scope: "world",
+ config: false,
+ type: Object,
+ default: _getDefaultFactionAspectState(),
+ })
}
/* ─── Template preload ───────────────────────────────────────────────────── */
@@ -349,17 +381,702 @@ function _preloadTemplates() {
/* ─── Socket handler ─────────────────────────────────────────────────────── */
-function _onSocketMessage(data) {
- if (!game.user.isGM) return
+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) actor.update({ "system.blessures.lvl": data.level })
+ 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 `
+
+ `
+}
+
+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 ─────────── */
/**
diff --git a/lang/fr.json b/lang/fr.json
index dd28f3f..e50480a 100644
--- a/lang/fr.json
+++ b/lang/fr.json
@@ -82,7 +82,17 @@
"Faction": {
"label": "Faction",
"relation": "Niveau de Relation",
+ "legendTitle": "Rappel des relations",
"custom": "Faction personnalisée…",
+ "levelAllies": "Alliés",
+ "levelAmicaux": "Amicaux",
+ "levelPartenaires": "Partenaires",
+ "levelBienveillants": "Bienveillants",
+ "levelNeutres": "Neutres",
+ "levelMefiants": "Méfiants",
+ "levelHostiles": "Hostiles",
+ "levelRivaux": "Rivaux",
+ "levelEnnemis": "Ennemis",
"pinkerton": "Agence Pinkerton",
"police": "Police secrète du duc",
"okhrana": "Okhrana",
@@ -92,6 +102,69 @@
"vorovskoymir": "Vorovskoy Mir",
"cour": "Cour des merveilles"
},
+ "FactionAspect": {
+ "title": "Aspects de faction",
+ "manage": "Gérer",
+ "managerTitle": "Gestion des Aspects de faction",
+ "managerState": "État du scénario",
+ "managerSettings": "Paramètres de groupe",
+ "officialSourcesTitle": "Factions officielles reconnues",
+ "officialSourcesHint": "En usage normal, les aspects disponibles proviennent automatiquement des factions principales des protagonistes.",
+ "officialSourcesEmpty": "Aucune faction officielle reconnue. Renseignez la faction principale des PJ sur leur fiche, ou activez une cellule indépendante si le groupe joue sa propre structure.",
+ "availablePoolTitle": "Aspects actuellement mobilisables",
+ "availablePoolHint": "Les joueurs choisissent collectivement un aspect disponible et la valeur qu'ils veulent lui attribuer, dans la limite de la réserve du scénario.",
+ "globalPoolLabel": "Pool global du scénario (MJ)",
+ "globalPoolHint": "Le MJ ajuste ici uniquement la réserve totale disponible pour le groupe au cours du scénario.",
+ "pointsMax": "Points disponibles",
+ "pointsSpent": "Points mobilisés",
+ "pointsRemaining": "Points restants",
+ "sources": "Sources disponibles",
+ "noSource": "Aucune faction de protagoniste reconnue pour le moment.",
+ "activeTitle": "Aspects mobilisés",
+ "noneActive": "Aucun aspect de faction mobilisé.",
+ "activateTitle": "Mobiliser un aspect",
+ "activateHint": "Une fois choisi par les joueurs et mobilisé ici, l'aspect reste figé pour tout le scénario, jusqu'au bouton « Nouveau scénario » ou à une correction manuelle du MJ.",
+ "activateAspect": "Aspect à mobiliser",
+ "activateValue": "Valeur",
+ "activateButton": "Mobiliser",
+ "removeAspect": "Retirer un aspect mobilisé",
+ "removeButton": "Retirer",
+ "customCell": "Cellule indépendante",
+ "customCellSection": "Cellule indépendante (optionnel)",
+ "customCellEnabled": "Utiliser une cellule indépendante",
+ "customCellHint": "Utilisez cette option uniquement si le groupe agit comme sa propre cellule ou si vous voulez compléter ou remplacer les factions officielles reconnues.",
+ "customCellName": "Nom de la cellule",
+ "customCellMode": "Mode de combinaison",
+ "modeReplace": "Remplace les factions officielles",
+ "modeExtend": "S'ajoute aux factions officielles",
+ "save": "Enregistrer",
+ "saved": "Paramètres des Aspects de faction enregistrés.",
+ "resetScenario": "Nouveau scénario",
+ "resetDone": "Les Aspects de faction mobilisés ont été réinitialisés.",
+ "activated": "{aspect} mobilisé à +{value}.",
+ "removed": "{aspect} (+{value}) retiré de la réserve mobilisée.",
+ "alreadyActive": "Cet aspect de faction est déjà mobilisé pour ce scénario.",
+ "invalidAspect": "Choisissez un aspect de faction disponible à mobiliser.",
+ "invalidRemove": "Choisissez un aspect de faction mobilisé à retirer.",
+ "notEnoughPoints": "La réserve d'Aspects de faction ne suffit pas pour cette mobilisation.",
+ "pointsBelowSpent": "Le total disponible ne peut pas être inférieur aux points déjà mobilisés.",
+ "customCellAspectCount": "Une cellule indépendante doit proposer entre 4 et 8 aspects.",
+ "gmOnly": "Seul le MJ peut gérer les Aspects de faction.",
+ "noAspectAvailable": "Aucun aspect disponible à mobiliser",
+ "legacyFactionValue": "Valeur héritée",
+ "rollLabel": "Aspect de faction",
+ "noneOption": "Aucun aspect de faction",
+ "bonnesadresses": "Bonnes adresses",
+ "contrebande": "Contrebande",
+ "corruption": "Corruption",
+ "diversion": "Diversion",
+ "falsification": "Falsification",
+ "passedroit": "Passe-droit",
+ "renforts": "Renforts",
+ "renseignements": "Renseignements",
+ "ressources": "Ressources",
+ "surveillance": "Surveillance"
+ },
"Track": {
"blessures": "Blessures",
"destin": "Destin",
@@ -99,7 +172,7 @@
"level": "Niveau",
"currentMalus": "Malus actuel",
"blessuresTooltip": "Niveaux de blessures :\n1–2 : Anodin / Négligeable → aucun malus (1 min)\n3–4 : Dérisoire / Superficiel → −1 (10 min)\n5–6 : Léger / Modéré → −2 (30 min)\n7 : Grave → −3 (1 journée)\n8 : Dramatique → hors combat",
- "spleenTooltip": "Le Spleen représente l'usure morale du protagoniste.\nLorsqu'il atteint son maximum, le protagoniste sombre dans la mélancolie et peut se retirer du scénario.",
+ "spleenTooltip": "Le Spleen représente l'usure morale du protagoniste.\nLa jauge de Spleen augmente avec les actions suivantes :\n• Lors d’un test de Spécialisation, en obtenant un échec et une Pleine lune sur le dé de la Lune.\n• Après un test de Spécialisation, pour transformer un échec en réussite, même après l’utilisation éventuelle d’une Anomalie.\n• Avant un test de résistance, pour réussir automatiquement le test.\n• Pour ne pas subir une blessure Dramatique.\n• En choisissant de puiser dans ses ressources.\nLorsque la jauge est remplie, le Protagoniste subit une Séquelle Dramatique et risque, à terme, de passer définitivement Hors Fiction.",
"destinTooltip": "Usages du Destin :\n• Réaliser un test avec 3d8\n• Gagner l'initiative lors d'un combat\n• Trouver l'ensemble des indices\n• Éviter une blessure\n• Sortir de l'inconscience\n• Obtenir un Triomphe"
},
"Wound": {
@@ -124,7 +197,7 @@
"corpsPnj": "Corps du PNJ",
"tie": "ÉGALITÉ",
"tieDesc": "Personne n'est blessé",
- "successHit": "PNJ touché — 1 blessure",
+ "successHit": "Attaque réussie — cible touchée",
"failureHit": "Joueur touché — 1 blessure (mêlée)",
"distanceNoWound": "Raté — pas de riposte",
"weaponDamage": "dégâts supplémentaires",
@@ -136,6 +209,25 @@
"rangedDefensePlayerWounded": "Blessure infligée par attaque à distance",
"targetLabel": "Cible",
"targetAuto": "Saisir manuellement",
+ "targetCharacterLabel": "Personnage visé",
+ "targetCharacterAuto": "Aucun personnage présélectionné",
+ "damageLabel": "Dégâts infligés",
+ "damageUnit": "blessure(s)",
+ "damageManual": "Dégâts variables : application manuelle par le MJ.",
+ "damageArmorReduction": "Protection d'armure",
+ "damageApplied": "Blessures après armure",
+ "applyDamage": "Appliquer les blessures",
+ "damageApplying": "Application...",
+ "damageApplyingNotice": "Application des blessures en cours...",
+ "damageAppliedDone": "Blessures appliquées",
+ "damageAppliedCard": "{actor} subit {wounds} blessure(s) après armure ({armor}).",
+ "damageNoEffectCard": "{actor} ne subit aucune blessure : l'armure absorbe l'attaque ({armor}).",
+ "selectCharacter": "Choisir une cible",
+ "selectCharacterFirst": "Sélectionnez une cible avant d'appliquer les blessures.",
+ "noCharacterTargetAvailable": "Aucune cible de la scène active disponible pour appliquer les blessures.",
+ "damageRequestSent": "Demande d'application des blessures envoyée au MJ.",
+ "damageAppliedNotify": "{actor} : {wounds} blessure(s) appliquée(s) après armure ({armor}).",
+ "damageNoEffectNotify": "{actor} : l'armure absorbe entièrement l'attaque ({armor}).",
"rangedMod": "Modificateur de tir",
"rangedModNone": "Aucun modificateur",
"rangedModAim": "Visée (dépense 1 tour) +2",
@@ -210,7 +302,8 @@
"resistanceTest": "Test de résistance",
"resistanceClickToRoll": "Lancer un test de résistance",
"woundTaken": "Blessure cochée suite à l'échec",
- "autoSuccess": "Réussite automatique"
+ "autoSuccess": "Réussite automatique",
+ "usedFactionAspect": "Aspect de faction mobilisé"
},
"Modifier": {
"evident": "Évident — Réussite automatique",
@@ -387,4 +480,4 @@
"factionNone": "Aucune faction"
}
}
-}
\ No newline at end of file
+}
diff --git a/module/applications/_module.mjs b/module/applications/_module.mjs
index 78acc48..935135d 100644
--- a/module/applications/_module.mjs
+++ b/module/applications/_module.mjs
@@ -1,3 +1,16 @@
+/**
+ * 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/
+ */
+
export { default as CelestopolCharacterSheet } from "./sheets/character-sheet.mjs"
export { default as CelestopolNPCSheet } from "./sheets/npc-sheet.mjs"
export { CelestopolAnomalySheet, CelestopolAspectSheet, CelestopolEquipmentSheet, CelestopolWeaponSheet, CelestopolArmureSheet } from "./sheets/item-sheets.mjs"
diff --git a/module/applications/sheets/base-actor-sheet.mjs b/module/applications/sheets/base-actor-sheet.mjs
index 4f35780..364456e 100644
--- a/module/applications/sheets/base-actor-sheet.mjs
+++ b/module/applications/sheets/base-actor-sheet.mjs
@@ -1,3 +1,16 @@
+/**
+ * Célestopol 1922 — Système FoundryVTT
+ *
+ * Célestopol 1922 est un jeu de rôle édité par Antre-Monde Éditions.
+ * Ce système FoundryVTT est une implémentation indépendante et n'est pas
+ * affilié à Antre-Monde Éditions,
+ * mais a été réalisé avec l'autorisation d'Antre-Monde Éditions.
+ *
+ * @author LeRatierBretonnien
+ * @copyright 2025–2026 LeRatierBretonnien
+ * @license CC BY-NC-SA 4.0 – https://creativecommons.org/licenses/by-nc-sa/4.0/
+ */
+
import { SYSTEM } from "../../config/system.mjs"
const { HandlebarsApplicationMixin } = foundry.applications.api
@@ -46,6 +59,7 @@ export default class CelestopolActorSheet extends HandlebarsApplicationMixin(fou
actor: this.document,
system: this.document.system,
source: this.document.toObject(),
+ isGM: game.user.isGM,
isEditMode: this.isEditMode,
isPlayMode: this.isPlayMode,
isEditable: this.isEditable,
diff --git a/module/applications/sheets/base-item-sheet.mjs b/module/applications/sheets/base-item-sheet.mjs
index 80110cc..0a52e03 100644
--- a/module/applications/sheets/base-item-sheet.mjs
+++ b/module/applications/sheets/base-item-sheet.mjs
@@ -1,3 +1,16 @@
+/**
+ * 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/
+ */
+
const { HandlebarsApplicationMixin } = foundry.applications.api
export default class CelestopolItemSheet extends HandlebarsApplicationMixin(foundry.applications.sheets.ItemSheetV2) {
diff --git a/module/applications/sheets/character-sheet.mjs b/module/applications/sheets/character-sheet.mjs
index 0355bdf..dafd827 100644
--- a/module/applications/sheets/character-sheet.mjs
+++ b/module/applications/sheets/character-sheet.mjs
@@ -1,3 +1,16 @@
+/**
+ * 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 CelestopolActorSheet from "./base-actor-sheet.mjs"
import { SYSTEM } from "../../config/system.mjs"
@@ -18,6 +31,7 @@ export default class CelestopolCharacterSheet extends CelestopolActorSheet {
depenseXp: CelestopolCharacterSheet.#onDepenseXp,
supprimerXpLog: CelestopolCharacterSheet.#onSupprimerXpLog,
rollMoonDie: CelestopolCharacterSheet.#onRollMoonDie,
+ manageFactionAspects: CelestopolCharacterSheet.#onManageFactionAspects,
},
}
@@ -58,6 +72,11 @@ export default class CelestopolCharacterSheet extends CelestopolActorSheet {
context.anomalyTypes = SYSTEM.ANOMALY_TYPES
context.factions = SYSTEM.FACTIONS
context.woundLevels = SYSTEM.WOUND_LEVELS
+ context.selectedPrimaryFactionId = game.celestopol?.normalizeFactionId(this.document.system.faction) || ""
+ context.legacyPrimaryFactionValue = this.document.system.faction && !context.selectedPrimaryFactionId
+ ? `${this.document.system.faction}`.trim()
+ : ""
+ context.primaryFactionLabel = game.celestopol?.getFactionDisplayLabel(this.document.system.faction) || this.document.system.faction
return context
}
@@ -94,6 +113,18 @@ export default class CelestopolCharacterSheet extends CelestopolActorSheet {
case "factions":
context.tab = context.tabs.factions
+ context.factionAspectSummary = game.celestopol?.getFactionAspectSummary(this.document) ?? null
+ context.factionLegend = [
+ { value: "+4", label: game.i18n.localize("CELESTOPOL.Faction.levelAllies") },
+ { value: "+3", label: game.i18n.localize("CELESTOPOL.Faction.levelAmicaux") },
+ { value: "+2", label: game.i18n.localize("CELESTOPOL.Faction.levelPartenaires") },
+ { value: "+1", label: game.i18n.localize("CELESTOPOL.Faction.levelBienveillants") },
+ { value: "0", label: game.i18n.localize("CELESTOPOL.Faction.levelNeutres") },
+ { value: "-1", label: game.i18n.localize("CELESTOPOL.Faction.levelMefiants") },
+ { value: "-2", label: game.i18n.localize("CELESTOPOL.Faction.levelHostiles") },
+ { value: "-3", label: game.i18n.localize("CELESTOPOL.Faction.levelRivaux") },
+ { value: "-4", label: game.i18n.localize("CELESTOPOL.Faction.levelEnnemis") },
+ ]
context.factionRows = Object.entries(SYSTEM.FACTIONS).map(([id, fDef]) => {
const val = this.document.system.factions[id]?.value ?? 0
return {
@@ -177,6 +208,7 @@ export default class CelestopolCharacterSheet extends CelestopolActorSheet {
static async #onCreateArmure() {
await this.document.createEmbeddedDocuments("Item", [{
name: game.i18n.localize("TYPES.Item.armure"), type: "armure",
+ system: { protection: 1, malus: 1 },
}])
}
@@ -199,6 +231,10 @@ export default class CelestopolCharacterSheet extends CelestopolActorSheet {
await anomaly.update({ "system.usesRemaining": anomaly.system.level })
}
+ static async #onManageFactionAspects() {
+ await game.celestopol?.manageFactionAspects(this.document)
+ }
+
/** Ouvre un dialogue pour dépenser de l'XP. */
static async #onDepenseXp() {
const actor = this.document
diff --git a/module/applications/sheets/item-sheets.mjs b/module/applications/sheets/item-sheets.mjs
index 1f67efe..cbe12ff 100644
--- a/module/applications/sheets/item-sheets.mjs
+++ b/module/applications/sheets/item-sheets.mjs
@@ -1,3 +1,16 @@
+/**
+ * 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 CelestopolItemSheet from "./base-item-sheet.mjs"
import { SYSTEM } from "../../config/system.mjs"
@@ -97,4 +110,22 @@ export class CelestopolArmureSheet extends CelestopolItemSheet {
this.document.system.description, { async: true })
return ctx
}
+
+ _onRender(context, options) {
+ super._onRender(context, options)
+
+ const protectionInput = this.element.querySelector('[name="system.protection"]')
+ const malusInput = this.element.querySelector('[name="system.malus"]')
+ const malusValue = this.element.querySelector('[data-armure-malus-value]')
+ if (!protectionInput || !malusInput || !malusValue) return
+
+ const syncMalus = () => {
+ malusInput.value = protectionInput.value
+ malusValue.textContent = protectionInput.value
+ }
+
+ syncMalus()
+ protectionInput.addEventListener("input", syncMalus)
+ protectionInput.addEventListener("change", syncMalus)
+ }
}
diff --git a/module/applications/sheets/npc-sheet.mjs b/module/applications/sheets/npc-sheet.mjs
index 337f4fc..4e60d2d 100644
--- a/module/applications/sheets/npc-sheet.mjs
+++ b/module/applications/sheets/npc-sheet.mjs
@@ -1,3 +1,16 @@
+/**
+ * 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 CelestopolActorSheet from "./base-actor-sheet.mjs"
import { SYSTEM } from "../../config/system.mjs"
@@ -9,6 +22,7 @@ export default class CelestopolNPCSheet extends CelestopolActorSheet {
window: { contentClasses: ["npc-content"] },
actions: {
createAspect: CelestopolNPCSheet.#onCreateAspect,
+ createEquipment: CelestopolNPCSheet.#onCreateEquipment,
createWeapon: CelestopolNPCSheet.#onCreateWeapon,
createArmure: CelestopolNPCSheet.#onCreateArmure,
rollMoonDie: CelestopolNPCSheet.#onRollMoonDie,
@@ -53,9 +67,10 @@ export default class CelestopolNPCSheet extends CelestopolActorSheet {
context.antagonisteStats = SYSTEM.ANTAGONISTE_STATS
const sys = this.document.system
- context.aspects = this.document.itemTypes.aspect ?? []
- context.weapons = this.document.itemTypes.weapon ?? []
- context.armures = this.document.itemTypes.armure ?? []
+ context.aspects = this.document.itemTypes.aspect ?? []
+ context.weapons = this.document.itemTypes.weapon.sort((a, b) => a.name.localeCompare(b.name))
+ context.armures = this.document.itemTypes.armure.sort((a, b) => a.name.localeCompare(b.name))
+ context.equipments = this.document.itemTypes.equipment.sort((a, b) => a.name.localeCompare(b.name))
context.armorMalus = sys.armorMalus ?? 0
// Label effectif de chaque domaine selon le type de PNJ
@@ -119,9 +134,16 @@ export default class CelestopolNPCSheet extends CelestopolActorSheet {
}])
}
+ static async #onCreateEquipment() {
+ await this.document.createEmbeddedDocuments("Item", [{
+ name: game.i18n.localize("TYPES.Item.equipment"), type: "equipment",
+ }])
+ }
+
static async #onCreateArmure() {
await this.document.createEmbeddedDocuments("Item", [{
name: game.i18n.localize("TYPES.Item.armure"), type: "armure",
+ system: { protection: 1, malus: 1 },
}])
}
diff --git a/module/config/system.mjs b/module/config/system.mjs
index 21739b4..775db21 100644
--- a/module/config/system.mjs
+++ b/module/config/system.mjs
@@ -1,3 +1,16 @@
+/**
+ * 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/
+ */
+
export const SYSTEM_ID = "fvtt-celestopol"
export const ASCII = `
@@ -88,6 +101,52 @@ export const FACTIONS = {
cour: { id: "cour", label: "CELESTOPOL.Faction.cour" },
}
+/** Aspects de faction mobilisables au niveau du groupe. */
+export const FACTION_ASPECTS = {
+ bonnesadresses: { id: "bonnesadresses", label: "CELESTOPOL.FactionAspect.bonnesadresses" },
+ contrebande: { id: "contrebande", label: "CELESTOPOL.FactionAspect.contrebande" },
+ corruption: { id: "corruption", label: "CELESTOPOL.FactionAspect.corruption" },
+ diversion: { id: "diversion", label: "CELESTOPOL.FactionAspect.diversion" },
+ falsification: { id: "falsification", label: "CELESTOPOL.FactionAspect.falsification" },
+ passedroit: { id: "passedroit", label: "CELESTOPOL.FactionAspect.passedroit" },
+ renforts: { id: "renforts", label: "CELESTOPOL.FactionAspect.renforts" },
+ renseignements: { id: "renseignements", label: "CELESTOPOL.FactionAspect.renseignements" },
+ ressources: { id: "ressources", label: "CELESTOPOL.FactionAspect.ressources" },
+ surveillance: { id: "surveillance", label: "CELESTOPOL.FactionAspect.surveillance" },
+}
+
+/** Tableau p.111 : aspects de faction disponibles selon l'organisation. */
+export const FACTION_ASPECTS_BY_FACTION = {
+ police: [
+ "diversion", "passedroit", "renforts", "renseignements", "ressources", "surveillance",
+ ],
+ vorovskoymir: [
+ "bonnesadresses", "contrebande", "corruption", "diversion", "falsification",
+ "renforts", "renseignements", "ressources", "surveillance",
+ ],
+ okhrana: [
+ "corruption", "diversion", "falsification", "passedroit", "renforts",
+ "renseignements", "ressources",
+ ],
+ oto: [
+ "contrebande", "corruption", "falsification", "renseignements", "surveillance",
+ ],
+ syndicats: [
+ "bonnesadresses", "contrebande", "corruption", "falsification", "renseignements", "surveillance",
+ ],
+ pinkerton: [
+ "bonnesadresses", "diversion", "falsification", "renforts",
+ "renseignements", "ressources", "surveillance",
+ ],
+ cour: [
+ "bonnesadresses", "contrebande", "diversion", "renforts", "renseignements", "surveillance",
+ ],
+ lunanovatek: [
+ "contrebande", "corruption", "falsification", "renforts",
+ "renseignements", "ressources", "surveillance",
+ ],
+}
+
/** Niveaux de blessures avec leur malus associé. */
export const WOUND_LEVELS = [
{ id: 0, label: "CELESTOPOL.Wound.none", malus: 0, duration: "" },
@@ -209,6 +268,8 @@ export const SYSTEM = {
ANOMALY_TYPES,
ANOMALY_DEFINITIONS,
FACTIONS,
+ FACTION_ASPECTS,
+ FACTION_ASPECTS_BY_FACTION,
NPC_TYPES,
ANTAGONISTE_STATS,
WOUND_LEVELS,
diff --git a/module/documents/_module.mjs b/module/documents/_module.mjs
index f0fca3e..83a204c 100644
--- a/module/documents/_module.mjs
+++ b/module/documents/_module.mjs
@@ -1,3 +1,16 @@
+/**
+ * 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/
+ */
+
export { default as CelestopolActor } from "./actor.mjs"
export { default as CelestopolItem } from "./item.mjs"
export { default as CelestopolChatMessage } from "./chat-message.mjs"
diff --git a/module/documents/actor.mjs b/module/documents/actor.mjs
index df33978..20b2788 100644
--- a/module/documents/actor.mjs
+++ b/module/documents/actor.mjs
@@ -1,3 +1,16 @@
+/**
+ * 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/
+ */
+
export default class CelestopolActor extends Actor {
/** @override */
getRollData() {
diff --git a/module/documents/chat-message.mjs b/module/documents/chat-message.mjs
index e63dc12..185d6cb 100644
--- a/module/documents/chat-message.mjs
+++ b/module/documents/chat-message.mjs
@@ -1 +1,14 @@
+/**
+ * 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/
+ */
+
export default class CelestopolChatMessage extends ChatMessage {}
diff --git a/module/documents/combat.mjs b/module/documents/combat.mjs
index 76eb1ed..dea68f5 100644
--- a/module/documents/combat.mjs
+++ b/module/documents/combat.mjs
@@ -1,3 +1,16 @@
+/**
+ * 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/
+ */
+
const SYSTEM_ID = "fvtt-celestopol"
export default class CelestopolCombat extends Combat {
diff --git a/module/documents/item.mjs b/module/documents/item.mjs
index 813e104..978b1e8 100644
--- a/module/documents/item.mjs
+++ b/module/documents/item.mjs
@@ -1,3 +1,16 @@
+/**
+ * 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/
+ */
+
export default class CelestopolItem extends Item {
/** @override */
getRollData() {
diff --git a/module/documents/roll.mjs b/module/documents/roll.mjs
index 0f11a28..19ab64e 100644
--- a/module/documents/roll.mjs
+++ b/module/documents/roll.mjs
@@ -1,3 +1,16 @@
+/**
+ * Célestopol 1922 — Système FoundryVTT
+ *
+ * Célestopol 1922 est un jeu de rôle édité par Antre-Monde Éditions.
+ * Ce système FoundryVTT est une implémentation indépendante et n'est pas
+ * affilié à Antre-Monde Éditions,
+ * mais a été réalisé avec l'autorisation d'Antre-Monde Éditions.
+ *
+ * @author LeRatierBretonnien
+ * @copyright 2025–2026 LeRatierBretonnien
+ * @license CC BY-NC-SA 4.0 – https://creativecommons.org/licenses/by-nc-sa/4.0/
+ */
+
import { SYSTEM } from "../config/system.mjs"
/** Construit la formule de jet à partir du nombre de dés et du modificateur total. */
@@ -29,6 +42,42 @@ export class CelestopolRoll extends Roll {
get skillLabel() { return this.options.skillLabel }
get difficulty() { return this.options.difficulty }
+ /**
+ * Convertit le niveau de dégâts d'une arme en nombre de blessures de base.
+ * Règle : une attaque réussie inflige toujours 1 blessure, plus le bonus de dégâts.
+ * @param {string|number|null} weaponDegats
+ * @returns {number|null}
+ */
+ static getIncomingWounds(weaponDegats) {
+ const raw = `${weaponDegats ?? "0"}`
+ const bonus = Number.parseInt(raw, 10)
+ if (!Number.isFinite(bonus)) return null
+ return Math.max(0, 1 + bonus)
+ }
+
+ /**
+ * Retourne la protection totale de l'armure équipée pour un acteur.
+ * @param {Actor|null} actor
+ * @returns {number}
+ */
+ static getActorArmorProtection(actor) {
+ if (!actor) return 0
+
+ if (typeof actor.system?.getArmorMalus === "function") {
+ return Math.abs(actor.system.getArmorMalus())
+ }
+
+ const derivedArmorMalus = actor.system?.armorMalus
+ if (Number.isFinite(derivedArmorMalus)) {
+ return Math.abs(derivedArmorMalus)
+ }
+
+ const armures = actor.itemTypes?.armure ?? []
+ return armures
+ .filter(a => a.system.equipped)
+ .reduce((sum, a) => sum + Math.abs(a.system.protection ?? a.system.malus ?? 0), 0)
+ }
+
/**
* Ouvre le dialogue de configuration du jet via DialogV2 et exécute le jet.
* @param {object} options
@@ -71,6 +120,8 @@ export class CelestopolRoll extends Roll {
value: m.value,
label: game.i18n.localize(m.label),
}))
+ const factionAspectChoices = game.celestopol?.getFactionAspectSummary(options.actorId ? game.actors.get(options.actorId) : null)
+ ?.availableAspectChoices ?? []
const dialogContext = {
actorName: options.actorName,
@@ -89,6 +140,7 @@ export class CelestopolRoll extends Roll {
aspectChoices,
situationChoices,
rangedModChoices,
+ factionAspectChoices,
availableTargets,
fortuneValue,
armorMalus,
@@ -123,7 +175,7 @@ export class CelestopolRoll extends Roll {
function applyTargetSelection() {
if (!targetSelect) return
const selectedOption = targetSelect.options[targetSelect.selectedIndex]
- const val = parseFloat(targetSelect.value)
+ const val = parseFloat(selectedOption?.dataset.corps ?? "")
const corpsPnjInput = wrap.querySelector('#corpsPnj')
if (targetSelect.value && !isNaN(val)) {
// Cible sélectionnée : masquer la valeur, afficher le nom
@@ -155,6 +207,8 @@ export class CelestopolRoll extends Roll {
const autoSucc = rawMod === "auto"
const modifier = autoSucc ? 0 : (parseInt(rawMod ?? 0) || 0)
const aspectMod = parseInt(wrap.querySelector('#aspectModifier')?.value ?? 0) || 0
+ const selectedFactionAspect = wrap.querySelector('#factionAspectId')?.selectedOptions?.[0]
+ const factionAspectBonus = parseInt(selectedFactionAspect?.dataset.value ?? 0) || 0
const situMod = parseInt(wrap.querySelector('#situationMod')?.value ?? 0) || 0
const rangedMod = parseInt(wrap.querySelector('#rangedMod')?.value ?? 0) || 0
const useDestin = wrap.querySelector('#useDestin')?.checked
@@ -180,7 +234,7 @@ export class CelestopolRoll extends Roll {
const effSit = puiser ? Math.max(0, situMod) : situMod
const effArmor = puiser ? 0 : armorMalus
const effRanged = puiser ? Math.max(0, rangedMod) : rangedMod
- const totalMod = skillValue + effWound + effMod + effAspect + effSit + effArmor + effRanged
+ const totalMod = skillValue + effWound + effMod + effAspect + factionAspectBonus + effSit + effArmor + effRanged
let formula
if (autoSucc) {
@@ -198,7 +252,7 @@ export class CelestopolRoll extends Roll {
if (previewEl) previewEl.textContent = formula
}
- wrap.querySelectorAll('#modifier, #aspectModifier, #situationMod, #rangedMod, #useDestin, #useFortune, #puiserRessources, #corpsPnj')
+ wrap.querySelectorAll('#modifier, #aspectModifier, #factionAspectId, #situationMod, #rangedMod, #useDestin, #useFortune, #puiserRessources, #corpsPnj')
.forEach(el => {
el.addEventListener('change', update)
el.addEventListener('input', update)
@@ -233,13 +287,23 @@ export class CelestopolRoll extends Roll {
const autoSuccess = rollContext.modifier === "auto"
const modifier = autoSuccess ? 0 : (parseInt(rollContext.modifier ?? 0) || 0)
const aspectMod = parseInt(rollContext.aspectModifier ?? 0) || 0
+ const factionAspectId = typeof rollContext.factionAspectId === "string" ? rollContext.factionAspectId : ""
+ const selectedFactionAspect = factionAspectChoices.find(choice => choice.id === factionAspectId) ?? null
+ const factionAspectBonus = selectedFactionAspect?.value ?? 0
+ const factionAspectLabel = selectedFactionAspect?.label ?? ""
const situationMod = parseInt(rollContext.situationMod ?? 0) || 0
const rangedMod = isRangedAttack ? (parseInt(rollContext.rangedMod ?? 0) || 0) : 0
- const isOpposition = !isCombat && !isResistance && (rollContext.isOpposition === true || rollContext.isOpposition === "true")
+ const isOpposition = !isCombat && (rollContext.isOpposition === true || rollContext.isOpposition === "true")
const useDestin = destGaugeFull && (rollContext.useDestin === true || rollContext.useDestin === "true")
const useFortune = fortuneValue > 0 && (rollContext.useFortune === true || rollContext.useFortune === "true")
const puiserRessources = rollContext.puiserRessources === true || rollContext.puiserRessources === "true"
const rollMoonDie = rollContext.rollMoonDie === true || rollContext.rollMoonDie === "true"
+ const selectedCombatTargetId = typeof rollContext.targetSelect === "string" ? rollContext.targetSelect : ""
+ const selectedCombatTarget = selectedCombatTargetId
+ ? availableTargets.find(t => t.id === selectedCombatTargetId) ?? null
+ : null
+ const targetActorId = selectedCombatTarget?.id || ""
+ const targetActorName = selectedCombatTarget?.name || ""
// En résistance : forcer puiser=false, lune=false, fortune=false, destin=false
const effectivePuiser = isResistance ? false : puiserRessources
@@ -255,7 +319,7 @@ export class CelestopolRoll extends Roll {
// Fortune : 1d8 + 8 ; Destin : 3d8 ; sinon : 2d8
const nbDice = (!isResistance && useDestin) ? 3 : 2
- const totalModifier = skillValue + effectiveWoundMalus + effectiveAspectMod + effectiveModifier + effectiveSituationMod + effectiveArmorMalus + effectiveRangedMod
+ const totalModifier = skillValue + effectiveWoundMalus + effectiveAspectMod + effectiveModifier + factionAspectBonus + effectiveSituationMod + effectiveArmorMalus + effectiveRangedMod
const formula = (!isResistance && useFortune)
? buildFormula(1, totalModifier + 8)
: buildFormula(nbDice, totalModifier)
@@ -277,6 +341,9 @@ export class CelestopolRoll extends Roll {
difficultyValue: diffConfig.value,
modifier: effectiveModifier,
aspectMod: effectiveAspectMod,
+ factionAspectId,
+ factionAspectLabel,
+ factionAspectBonus,
situationMod: effectiveSituationMod,
woundMalus: effectiveWoundMalus,
autoSuccess,
@@ -287,6 +354,9 @@ export class CelestopolRoll extends Roll {
weaponType,
weaponName,
weaponDegats,
+ targetActorId,
+ targetActorName,
+ availableTargets,
rangedMod: effectiveRangedMod,
useDestin: !isResistance && useDestin,
useFortune: !isResistance && useFortune,
@@ -410,8 +480,10 @@ export class CelestopolRoll extends Roll {
: 11
const margin = this.options.margin
const woundMalus = this.options.woundMalus ?? 0
+ const armorMalus = this.options.armorMalus ?? 0
const skillValue = this.options.skillValue ?? 0
const woundLevelId = this.options.woundLevel ?? 0
+ const weaponDegats = `${this.options.weaponDegats ?? "0"}`
const woundLabel = woundLevelId > 0
? game.i18n.localize(SYSTEM.WOUND_LEVELS[woundLevelId]?.label ?? "")
: null
@@ -430,6 +502,22 @@ export class CelestopolRoll extends Roll {
}
const isOpposition = this.options.isOpposition ?? false
+ const isWeaponHit = (this.options.isCombat ?? false) && !(this.options.isRangedDefense ?? false) && this.isSuccess
+ const incomingWounds = isWeaponHit ? this.constructor.getIncomingWounds(weaponDegats) : null
+ const hasVariableDamage = isWeaponHit && incomingWounds === null
+ const targetActorId = this.options.targetActorId ?? ""
+ const targetActorName = this.options.targetActorName ?? ""
+ const availableTargets = (this.options.availableTargets ?? []).map(target => ({
+ ...target,
+ selected: target.id === targetActorId,
+ }))
+ const selectedTargetActor = targetActorId ? game.actors.get(targetActorId) : null
+ const selectedTargetProtection = selectedTargetActor
+ ? this.constructor.getActorArmorProtection(selectedTargetActor)
+ : null
+ const selectedTargetAppliedWounds = (incomingWounds !== null && selectedTargetActor)
+ ? Math.max(0, incomingWounds - selectedTargetProtection)
+ : null
// Libellé de difficulté : en combat "Corps PNJ : N", en opposition "vs ?", sinon "Seuil : 11"
const difficultyLabel = this.options.isCombat
@@ -464,22 +552,35 @@ export class CelestopolRoll extends Roll {
modifier: this.options.modifier ?? 0,
autoSuccess: this.options.autoSuccess ?? false,
aspectMod: this.options.aspectMod ?? 0,
+ factionAspectLabel: this.options.factionAspectLabel ?? "",
+ factionAspectBonus: this.options.factionAspectBonus ?? 0,
skillValue,
useDestin: this.options.useDestin ?? false,
useFortune: this.options.useFortune ?? false,
puiserRessources: this.options.puiserRessources ?? false,
nbDice: this.options.nbDice ?? diceResults.length,
woundMalus,
+ armorMalus,
woundLabel,
isResistance: this.options.isResistance ?? false,
isCombat: this.options.isCombat ?? false,
weaponName: this.options.weaponName ?? null,
- weaponDegats: this.options.weaponDegats ?? null,
+ weaponDegats,
weaponType: this.options.weaponType ?? null,
isRangedDefense: this.options.isRangedDefense ?? false,
woundTaken: this.options.woundTaken ?? null,
situationMod: this.options.situationMod ?? 0,
rangedMod: this.options.rangedMod ?? 0,
+ hasDamageSummary: isWeaponHit,
+ incomingWounds,
+ incomingWoundsDisplay: incomingWounds ?? "1 + X",
+ hasVariableDamage,
+ canApplyWeaponDamage: incomingWounds !== null,
+ targetActorId,
+ targetActorName,
+ selectedTargetProtection,
+ selectedTargetAppliedWounds,
+ availableTargets,
// Dé de lune
hasMoonDie: moonDieResult !== null,
moonDieResult,
diff --git a/module/models/_module.mjs b/module/models/_module.mjs
index d01eccb..092c55c 100644
--- a/module/models/_module.mjs
+++ b/module/models/_module.mjs
@@ -1,3 +1,16 @@
+/**
+ * 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/
+ */
+
export { default as CelestopolCharacter } from "./character.mjs"
export { default as CelestopolNPC } from "./npc.mjs"
export { CelestopolAnomaly, CelestopolAspect, CelestopolEquipment, CelestopolWeapon, CelestopolArmure } from "./items.mjs"
diff --git a/module/models/character.mjs b/module/models/character.mjs
index a47f28a..fb8c478 100644
--- a/module/models/character.mjs
+++ b/module/models/character.mjs
@@ -1,3 +1,16 @@
+/**
+ * Célestopol 1922 — Système FoundryVTT
+ *
+ * Célestopol 1922 est un jeu de rôle édité par Antre-Monde Éditions.
+ * Ce système FoundryVTT est une implémentation indépendante et n'est pas
+ * affilié à Antre-Monde Éditions,
+ * mais a été réalisé avec l'autorisation d'Antre-Monde Éditions.
+ *
+ * @author LeRatierBretonnien
+ * @copyright 2025–2026 LeRatierBretonnien
+ * @license CC BY-NC-SA 4.0 – https://creativecommons.org/licenses/by-nc-sa/4.0/
+ */
+
import { SYSTEM } from "../config/system.mjs"
export default class CelestopolCharacter extends foundry.abstract.TypeDataModel {
@@ -165,8 +178,21 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
getArmorMalus() {
if (!this.parent) return 0
return -(this.parent.itemTypes.armure
- .filter(a => a.system.equipped && a.system.malus > 0)
- .reduce((sum, a) => sum + a.system.malus, 0))
+ .filter(a => a.system.equipped && (a.system.protection ?? a.system.malus) > 0)
+ .reduce((sum, a) => sum + (a.system.protection ?? a.system.malus), 0))
+ }
+
+ /**
+ * Retourne le malus d'armure applicable pour un jet PJ.
+ * Règle : uniquement sur Mobilité et Effacement si l'armure est équipée.
+ * @param {string} statId
+ * @param {string|null} skillId
+ * @returns {number}
+ */
+ getArmorMalusForRoll(statId, skillId = null) {
+ if (statId !== "corps") return 0
+ if (!["mobilite", "effacement"].includes(skillId)) return 0
+ return this.getArmorMalus()
}
/**
@@ -198,7 +224,7 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
skillLabel: skill.label,
skillValue: skill.value,
woundMalus: this.getWoundMalus(),
- armorMalus: this.getArmorMalus(),
+ armorMalus: this.getArmorMalusForRoll(statId, skillId),
woundLevel: this.blessures.lvl,
difficulty: this.prefs.difficulty,
rollMoonDie: this.prefs.rollMoonDie ?? false,
@@ -229,7 +255,7 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
skillLabel: "CELESTOPOL.Roll.resistanceTest",
skillValue: statData.res,
woundMalus: this.getWoundMalus(),
- armorMalus: this.getArmorMalus(),
+ armorMalus: 0,
woundLevel: this.blessures.lvl,
isResistance: true,
rollMoonDie: false,
@@ -240,8 +266,8 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
}
/**
- * Collecte les tokens PNJs disponibles comme cibles de combat.
- * Priorise le combat tracker, sinon les tokens ciblés par l'utilisateur.
+ * Collecte les cibles de combat sur la scène active.
+ * Pour un PJ attaquant, seules les cibles PNJ présentes sur la scène sont proposées.
* @returns {Array<{id:string, name:string, corps:number}>}
*/
_getCombatTargets() {
@@ -250,25 +276,13 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
name: actor.name,
corps: actor.system.stats?.corps?.res ?? 0,
})
- // Priorité 1 : PNJs dans le combat actif
- if (game.combat?.active) {
- const list = game.combat.combatants
- .filter(c => c.actor?.type === "npc" && c.actorId !== this.parent.id)
- .map(c => toEntry(c.actor))
- if (list.length) return list
- }
- // Priorité 2 : Tokens ciblés par le joueur
- const targeted = [...(game.user?.targets ?? [])]
- .filter(t => t.actor?.type === "npc")
- .map(t => toEntry(t.actor))
- if (targeted.length) return targeted
- // Priorité 3 : Tous les tokens NPC de la scène active
- if (canvas?.tokens?.placeables) {
- return canvas.tokens.placeables
- .filter(t => t.actor?.type === "npc" && t.actor.id !== this.parent.id)
- .map(t => toEntry(t.actor))
- }
- return []
+ const sceneTokens = canvas?.scene?.isView ? (canvas.tokens?.placeables ?? []) : []
+ return [...new Map(sceneTokens
+ .filter(t => t.actor?.type === "npc" && t.actor.id !== this.parent.id)
+ .map(t => {
+ const actor = t.actor
+ return [actor.id, toEntry(actor)]
+ })).values()]
}
/**
@@ -296,7 +310,7 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
skillLabel: SYSTEM.SKILLS.corps.echauffouree.label,
skillValue: echauffouree.value,
woundMalus: this.getWoundMalus(),
- armorMalus: this.getArmorMalus(),
+ armorMalus: this.getArmorMalusForRoll("corps", "echauffouree"),
woundLevel: this.blessures.lvl,
rollMoonDie: this.prefs.rollMoonDie ?? false,
destGaugeFull: this.destin.lvl > 0,
@@ -334,7 +348,7 @@ export default class CelestopolCharacter extends foundry.abstract.TypeDataModel
skillLabel: SYSTEM.SKILLS.corps.mobilite.label,
skillValue: mobilite.value,
woundMalus: this.getWoundMalus(),
- armorMalus: this.getArmorMalus(),
+ armorMalus: this.getArmorMalusForRoll("corps", "mobilite"),
woundLevel: this.blessures.lvl,
rollMoonDie: this.prefs.rollMoonDie ?? false,
destGaugeFull: this.destin.lvl > 0,
diff --git a/module/models/items.mjs b/module/models/items.mjs
index 180e261..ed16c69 100644
--- a/module/models/items.mjs
+++ b/module/models/items.mjs
@@ -1,3 +1,16 @@
+/**
+ * Célestopol 1922 — Système FoundryVTT
+ *
+ * Célestopol 1922 est un jeu de rôle édité par Antre-Monde Éditions.
+ * Ce système FoundryVTT est une implémentation indépendante et n'est pas
+ * affilié à Antre-Monde Éditions,
+ * mais a été réalisé avec l'autorisation d'Antre-Monde Éditions.
+ *
+ * @author LeRatierBretonnien
+ * @copyright 2025–2026 LeRatierBretonnien
+ * @license CC BY-NC-SA 4.0 – https://creativecommons.org/licenses/by-nc-sa/4.0/
+ */
+
import { SYSTEM } from "../config/system.mjs"
/** Schéma partagé pour les bonus/malus par domaine (utilisé dans anomaly/aspect). */
@@ -83,9 +96,14 @@ export class CelestopolArmure extends foundry.abstract.TypeDataModel {
const reqInt = { required: true, nullable: false, integer: true }
return {
protection: new fields.NumberField({ ...reqInt, initial: 1, min: 1, max: 2 }),
- malus: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 2 }),
+ malus: new fields.NumberField({ ...reqInt, initial: 1, min: 0, max: 2 }),
equipped: new fields.BooleanField({ initial: false }),
description: new fields.HTMLField({ required: true, textSearch: true }),
}
}
+
+ prepareDerivedData() {
+ super.prepareDerivedData()
+ this.malus = this.protection
+ }
}
diff --git a/module/models/npc.mjs b/module/models/npc.mjs
index ce3c51c..e89f97a 100644
--- a/module/models/npc.mjs
+++ b/module/models/npc.mjs
@@ -1,3 +1,16 @@
+/**
+ * Célestopol 1922 — Système FoundryVTT
+ *
+ * Célestopol 1922 est un jeu de rôle édité par Antre-Monde Éditions.
+ * Ce système FoundryVTT est une implémentation indépendante et n'est pas
+ * affilié à Antre-Monde Éditions,
+ * mais a été réalisé avec l'autorisation d'Antre-Monde Éditions.
+ *
+ * @author LeRatierBretonnien
+ * @copyright 2025–2026 LeRatierBretonnien
+ * @license CC BY-NC-SA 4.0 – https://creativecommons.org/licenses/by-nc-sa/4.0/
+ */
+
import { SYSTEM } from "../config/system.mjs"
export default class CelestopolNPC extends foundry.abstract.TypeDataModel {
@@ -66,7 +79,20 @@ export default class CelestopolNPC extends foundry.abstract.TypeDataModel {
const armures = this.parent?.itemTypes?.armure ?? []
return armures
.filter(a => a.system.equipped)
- .reduce((sum, a) => sum + (a.system.malus ? -Math.abs(a.system.malus) : 0), 0)
+ .reduce((sum, a) => {
+ const value = a.system.protection ?? a.system.malus
+ return sum + (value ? -Math.abs(value) : 0)
+ }, 0)
+ }
+
+ /**
+ * Retourne le malus d'armure applicable pour un jet PNJ.
+ * Règle : sur tous les jets de Corps uniquement.
+ * @param {string} statId
+ * @returns {number}
+ */
+ getArmorMalusForRoll(statId) {
+ return statId === "corps" ? this.getArmorMalus() : 0
}
/**
@@ -91,7 +117,7 @@ export default class CelestopolNPC extends foundry.abstract.TypeDataModel {
skillLabel,
skillValue: statData.res,
woundMalus: this.getWoundMalus(),
- armorMalus: this.getArmorMalus(),
+ armorMalus: this.getArmorMalusForRoll(statId),
woundLevel: this.blessures.lvl,
})
}
diff --git a/packs-system/anomalies/000019.log b/packs-system/anomalies/000019.log
deleted file mode 100644
index 81cfb05..0000000
Binary files a/packs-system/anomalies/000019.log and /dev/null differ
diff --git a/packs-system/anomalies/000030.log b/packs-system/anomalies/000030.log
new file mode 100644
index 0000000..e69de29
diff --git a/packs-system/anomalies/000017.ldb b/packs-system/anomalies/000032.ldb
similarity index 97%
rename from packs-system/anomalies/000017.ldb
rename to packs-system/anomalies/000032.ldb
index 363cf2e..d232d77 100644
Binary files a/packs-system/anomalies/000017.ldb and b/packs-system/anomalies/000032.ldb differ
diff --git a/packs-system/anomalies/CURRENT b/packs-system/anomalies/CURRENT
index e417a51..5d0fffb 100644
--- a/packs-system/anomalies/CURRENT
+++ b/packs-system/anomalies/CURRENT
@@ -1 +1 @@
-MANIFEST-000018
+MANIFEST-000028
diff --git a/packs-system/anomalies/LOG b/packs-system/anomalies/LOG
index ad3c26f..fa0b0f1 100644
--- a/packs-system/anomalies/LOG
+++ b/packs-system/anomalies/LOG
@@ -1,3 +1,15 @@
-2026/04/06-17:46:52.532955 7f67ebfff6c0 Recovering log #15
-2026/04/06-17:46:52.543005 7f67ebfff6c0 Delete type=3 #13
-2026/04/06-17:46:52.543081 7f67ebfff6c0 Delete type=0 #15
+2026/04/11-14:57:57.789089 7f20ee3ff6c0 Recovering log #25
+2026/04/11-14:57:57.798668 7f20ee3ff6c0 Delete type=3 #23
+2026/04/11-14:57:57.798745 7f20ee3ff6c0 Delete type=0 #25
+2026/04/11-14:58:32.475719 7f1e4ffff6c0 Level-0 table #31: started
+2026/04/11-14:58:32.479327 7f1e4ffff6c0 Level-0 table #31: 3524 bytes OK
+2026/04/11-14:58:32.485215 7f1e4ffff6c0 Delete type=0 #29
+2026/04/11-14:58:32.485355 7f1e4ffff6c0 Manual compaction at level-0 from '!items!anomCommMorts001' @ 72057594037927935 : 1 .. '!items!null' @ 0 : 0; will stop at (end)
+2026/04/11-14:58:32.485398 7f1e4ffff6c0 Manual compaction at level-1 from '!items!anomCommMorts001' @ 72057594037927935 : 1 .. '!items!null' @ 0 : 0; will stop at '!items!null' @ 29 : 1
+2026/04/11-14:58:32.485404 7f1e4ffff6c0 Compacting 1@1 + 1@2 files
+2026/04/11-14:58:32.488550 7f1e4ffff6c0 Generated table #32@1: 9 keys, 6617 bytes
+2026/04/11-14:58:32.488570 7f1e4ffff6c0 Compacted 1@1 + 1@2 files => 6617 bytes
+2026/04/11-14:58:32.494415 7f1e4ffff6c0 compacted to: files[ 0 0 1 0 0 0 0 ]
+2026/04/11-14:58:32.494503 7f1e4ffff6c0 Delete type=2 #27
+2026/04/11-14:58:32.494598 7f1e4ffff6c0 Delete type=2 #31
+2026/04/11-14:58:32.513712 7f1e4ffff6c0 Manual compaction at level-1 from '!items!null' @ 29 : 1 .. '!items!null' @ 0 : 0; will stop at (end)
diff --git a/packs-system/anomalies/LOG.old b/packs-system/anomalies/LOG.old
index 00a7d2b..f817ac7 100644
--- a/packs-system/anomalies/LOG.old
+++ b/packs-system/anomalies/LOG.old
@@ -1,15 +1,15 @@
-2026/04/05-21:02:44.634018 7f8249dff6c0 Recovering log #10
-2026/04/05-21:02:44.729398 7f8249dff6c0 Delete type=3 #8
-2026/04/05-21:02:44.729470 7f8249dff6c0 Delete type=0 #10
-2026/04/06-00:09:38.933436 7f82177fe6c0 Level-0 table #16: started
-2026/04/06-00:09:38.937122 7f82177fe6c0 Level-0 table #16: 3525 bytes OK
-2026/04/06-00:09:38.943462 7f82177fe6c0 Delete type=0 #14
-2026/04/06-00:09:38.943723 7f82177fe6c0 Manual compaction at level-0 from '!items!anomCommMorts001' @ 72057594037927935 : 1 .. '!items!null' @ 0 : 0; will stop at (end)
-2026/04/06-00:09:38.966124 7f82177fe6c0 Manual compaction at level-1 from '!items!anomCommMorts001' @ 72057594037927935 : 1 .. '!items!null' @ 0 : 0; will stop at '!items!null' @ 17 : 1
-2026/04/06-00:09:38.966141 7f82177fe6c0 Compacting 1@1 + 1@2 files
-2026/04/06-00:09:38.969869 7f82177fe6c0 Generated table #17@1: 9 keys, 6617 bytes
-2026/04/06-00:09:38.969906 7f82177fe6c0 Compacted 1@1 + 1@2 files => 6617 bytes
-2026/04/06-00:09:38.976148 7f82177fe6c0 compacted to: files[ 0 0 1 0 0 0 0 ]
-2026/04/06-00:09:38.976266 7f82177fe6c0 Delete type=2 #12
-2026/04/06-00:09:38.976457 7f82177fe6c0 Delete type=2 #16
-2026/04/06-00:09:38.987710 7f82177fe6c0 Manual compaction at level-1 from '!items!null' @ 17 : 1 .. '!items!null' @ 0 : 0; will stop at (end)
+2026/04/11-09:06:09.549060 7f20edbfe6c0 Recovering log #20
+2026/04/11-09:06:09.559919 7f20edbfe6c0 Delete type=3 #18
+2026/04/11-09:06:09.559969 7f20edbfe6c0 Delete type=0 #20
+2026/04/11-14:57:53.882273 7f1e4ffff6c0 Level-0 table #26: started
+2026/04/11-14:57:53.885416 7f1e4ffff6c0 Level-0 table #26: 3524 bytes OK
+2026/04/11-14:57:53.891947 7f1e4ffff6c0 Delete type=0 #24
+2026/04/11-14:57:53.892102 7f1e4ffff6c0 Manual compaction at level-0 from '!items!anomCommMorts001' @ 72057594037927935 : 1 .. '!items!null' @ 0 : 0; will stop at (end)
+2026/04/11-14:57:53.902087 7f1e4ffff6c0 Manual compaction at level-1 from '!items!anomCommMorts001' @ 72057594037927935 : 1 .. '!items!null' @ 0 : 0; will stop at '!items!null' @ 25 : 1
+2026/04/11-14:57:53.902094 7f1e4ffff6c0 Compacting 1@1 + 1@2 files
+2026/04/11-14:57:53.905146 7f1e4ffff6c0 Generated table #27@1: 9 keys, 6617 bytes
+2026/04/11-14:57:53.905170 7f1e4ffff6c0 Compacted 1@1 + 1@2 files => 6617 bytes
+2026/04/11-14:57:53.911010 7f1e4ffff6c0 compacted to: files[ 0 0 1 0 0 0 0 ]
+2026/04/11-14:57:53.911117 7f1e4ffff6c0 Delete type=2 #22
+2026/04/11-14:57:53.911253 7f1e4ffff6c0 Delete type=2 #26
+2026/04/11-14:57:53.928122 7f1e4ffff6c0 Manual compaction at level-1 from '!items!null' @ 25 : 1 .. '!items!null' @ 0 : 0; will stop at (end)
diff --git a/packs-system/anomalies/MANIFEST-000018 b/packs-system/anomalies/MANIFEST-000018
deleted file mode 100644
index 3405437..0000000
Binary files a/packs-system/anomalies/MANIFEST-000018 and /dev/null differ
diff --git a/packs-system/anomalies/MANIFEST-000028 b/packs-system/anomalies/MANIFEST-000028
new file mode 100644
index 0000000..f9e48bd
Binary files /dev/null and b/packs-system/anomalies/MANIFEST-000028 differ
diff --git a/styles/character.less b/styles/character.less
index 4a15acb..de30dca 100644
--- a/styles/character.less
+++ b/styles/character.less
@@ -355,6 +355,145 @@
}
}
+ .factions-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 210px;
+ gap: 12px;
+ align-items: start;
+ }
+
+ .faction-aspect-summary {
+ grid-column: 1 / -1;
+ border: 1px solid rgba(122,92,32,0.35);
+ background: linear-gradient(180deg, rgba(255,248,232,0.95), rgba(240,229,209,0.9));
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.35);
+ padding: 7px 9px;
+
+ .faction-aspect-summary-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 6px;
+ }
+
+ .faction-aspect-summary-title {
+ color: var(--cel-green);
+ font-family: var(--cel-font-title);
+ font-size: 1em;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ line-height: 1.05;
+ }
+
+ .faction-aspect-manage {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--cel-orange);
+ cursor: pointer;
+ text-decoration: none;
+ font-size: 0.84em;
+ white-space: nowrap;
+ }
+
+ .faction-aspect-points {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 6px;
+ margin-bottom: 6px;
+ }
+
+ .faction-aspect-point {
+ min-width: 0;
+ padding: 4px 6px;
+ border: 1px solid rgba(122,92,32,0.25);
+ background: rgba(255,255,255,0.45);
+ border-radius: 4px;
+
+ .label {
+ display: block;
+ font-size: 0.66em;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: var(--cel-border);
+ line-height: 1.1;
+ }
+
+ .value {
+ color: var(--cel-orange);
+ font-family: var(--cel-font-title);
+ font-size: 1.02em;
+ line-height: 1;
+ }
+ }
+
+ .faction-aspect-source-line {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-bottom: 6px;
+ font-size: 0.8em;
+ line-height: 1.2;
+ color: var(--cel-green);
+ }
+
+ .faction-aspect-source {
+ padding: 0 6px;
+ border-radius: 9px;
+ background: rgba(12,76,12,0.08);
+ border: 1px solid rgba(12,76,12,0.2);
+ }
+
+ .faction-aspect-active-title {
+ margin-bottom: 4px;
+ color: var(--cel-border);
+ font-size: 0.68em;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ line-height: 1.1;
+ }
+
+ .faction-aspect-active-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ }
+
+ .faction-aspect-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 2px 6px;
+ border-radius: 999px;
+ border: 1px solid rgba(122,92,32,0.25);
+ background: rgba(255,255,255,0.55);
+ line-height: 1.1;
+
+ &.is-relevant {
+ border-color: rgba(12,76,12,0.35);
+ background: rgba(12,76,12,0.1);
+ }
+
+ .name {
+ color: var(--cel-green);
+ font-size: 0.88em;
+ }
+
+ .value {
+ color: var(--cel-orange);
+ font-family: var(--cel-font-title);
+ font-size: 0.9em;
+ }
+ }
+
+ .faction-aspect-empty {
+ color: #666;
+ font-style: italic;
+ font-size: 0.84em;
+ }
+ }
+
// Factions table
.factions-table {
width: 100%;
@@ -396,12 +535,9 @@
background: rgba(255,255,255,0.3);
transition: background 0.1s;
&[data-action] { cursor: pointer; }
- // Dot neutre (centre, index 4)
&.neutral { border-color: #888; }
&.neutral.filled { background: #aaa; border-color: #888; }
- // Dots positifs (alliés) → or
&.pos.filled { background: var(--cel-orange); border-color: var(--cel-orange); }
- // Dots négatifs (hostiles) → rouge terracotta
&.neg.filled { background: #b84a2e; border-color: #b84a2e; }
}
@@ -421,6 +557,48 @@
}
}
+ .factions-legend {
+ border: 1px solid rgba(122,92,32,0.35);
+ background: linear-gradient(180deg, rgba(255,248,232,0.95), rgba(240,229,209,0.9));
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.35);
+ padding: 8px 10px;
+
+ .factions-legend-title {
+ margin-bottom: 6px;
+ padding-bottom: 4px;
+ border-bottom: 1px solid rgba(122,92,32,0.25);
+ color: var(--cel-green);
+ font-family: var(--cel-font-title);
+ font-size: 1.05em;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ }
+
+ .factions-legend-list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ font-size: 0.88em;
+ }
+
+ .factions-legend-row {
+ display: grid;
+ grid-template-columns: 28px minmax(0, 1fr);
+ gap: 8px;
+ align-items: baseline;
+ }
+
+ .factions-legend-value {
+ color: var(--cel-orange);
+ font-weight: bold;
+ text-align: right;
+ }
+
+ .factions-legend-label {
+ color: var(--cel-green);
+ }
+ }
+
// Biography / Equipment
.equipments-section {
margin-bottom: 12px;
@@ -446,6 +624,7 @@
.item-row {
.cel-item-row();
+ gap: 10px;
&.is-equipped {
background: rgba(12, 76, 12, 0.12);
@@ -469,6 +648,22 @@
&.equipped { color: var(--cel-green); }
&:hover { color: var(--cel-orange); }
}
+
+ .item-controls {
+ opacity: 1;
+ gap: 10px;
+ flex-shrink: 0;
+
+ a {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 20px;
+ min-height: 20px;
+ font-size: 1.08rem;
+ line-height: 1;
+ }
+ }
}
.equip-empty {
diff --git a/styles/global.less b/styles/global.less
index 39b7265..55ca295 100644
--- a/styles/global.less
+++ b/styles/global.less
@@ -221,6 +221,10 @@
width: 30px;
height: 30px;
}
+
+ .manage-faction-aspects-btn {
+ font-size: 1em;
+ }
}
}
diff --git a/styles/items.less b/styles/items.less
index 6a6f621..72443e8 100644
--- a/styles/items.less
+++ b/styles/items.less
@@ -415,13 +415,18 @@
}
.equipped-box {
border-color: var(--cel-green);
+ min-width: 170px;
.equipped-switch {
display: flex;
align-items: center;
- gap: 6px;
+ justify-content: center;
+ width: 100%;
cursor: pointer;
input[type="checkbox"] { display: none; }
.switch-label {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
font-family: var(--cel-font-ui);
font-size: 0.9em;
color: var(--cel-border);
@@ -429,7 +434,13 @@
border: 1px solid var(--cel-border);
border-radius: 20px;
transition: all 0.2s;
- white-space: nowrap;
+ width: 100%;
+ max-width: 100%;
+ min-height: 34px;
+ line-height: 1.2;
+ text-align: center;
+ white-space: normal;
+ overflow-wrap: anywhere;
&.on {
color: var(--cel-green-light);
border-color: var(--cel-green);
diff --git a/styles/npc.less b/styles/npc.less
index 639cb03..76c4cd2 100644
--- a/styles/npc.less
+++ b/styles/npc.less
@@ -251,7 +251,7 @@
.track-section {
border: 1px solid var(--cel-border);
border-radius: 4px;
- margin-bottom: 8px;
+ margin-bottom: 12px;
overflow: hidden;
.track-header {
@@ -259,27 +259,78 @@
background-image: url("../assets/ui/fond_cadrille.jpg");
background-blend-mode: soft-light;
color: var(--cel-orange);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
padding: 5px 8px;
- font-family: var(--cel-font-title);
- font-weight: bold;
- text-transform: uppercase;
- font-size: 0.9em;
- letter-spacing: 0.06em;
border-bottom: 1px solid rgba(196,154,26,0.4);
+
+ .track-title {
+ font-family: var(--cel-font-title);
+ font-weight: bold;
+ text-transform: uppercase;
+ font-size: 1.1em;
+ letter-spacing: 0.04em;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ }
+
+ .track-help {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ border: 1px solid currentColor;
+ font-size: 0.65em;
+ font-family: var(--cel-font-body);
+ font-weight: bold;
+ text-transform: none;
+ letter-spacing: 0;
+ cursor: help;
+ opacity: 0.7;
+ transition: opacity 0.15s;
+ flex-shrink: 0;
+ &:hover { opacity: 1; }
+ }
}
.track-boxes {
display: flex;
padding: 8px;
- gap: 6px;
+ gap: 8px;
+ flex-wrap: wrap;
background: var(--cel-cream);
.track-box {
display: flex;
flex-direction: column;
align-items: center;
+ justify-content: center;
gap: 2px;
- .box-label { font-size: 0.65em; color: var(--cel-border); }
+ width: 22px;
+ min-height: 22px;
+ border: 2px solid var(--cel-border);
+ border-radius: 2px;
+ background: rgba(255,255,255,0.45);
+ transition: background 0.1s, border-color 0.1s;
+
+ &.filled {
+ background: var(--cel-orange);
+ border-color: var(--cel-orange);
+ }
+
+ &[data-action] { cursor: pointer; }
+
+ .box-label {
+ font-size: 0.6em;
+ color: var(--cel-border);
+ line-height: 1;
+ }
+
+ &.filled .box-label { color: rgba(30,10,0,0.65); }
}
}
@@ -290,11 +341,84 @@
padding: 4px 8px;
background: rgba(139,115,85,0.1);
font-size: 0.85em;
+ label { color: var(--cel-border); }
input[type="number"] { width: 40px; .cel-input-std(); }
}
}
- .description-section {
+ .tab.equipement {
+ .equip-section {
+ margin-bottom: 14px;
+
+ .section-header {
+ .cel-section-header();
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ i { opacity: 0.75; }
+ span { flex: 1; }
+ a { color: var(--cel-orange); cursor: pointer; }
+ }
+
+ .item-row {
+ .cel-item-row();
+ gap: 10px;
+
+ &.is-equipped {
+ background: rgba(12, 76, 12, 0.12);
+ border-left: 3px solid var(--cel-green);
+ padding-left: 5px;
+ }
+
+ .item-tag {
+ font-size: 0.75em;
+ padding: 1px 7px;
+ border-radius: 10px;
+ background: rgba(12,76,12,0.15);
+ border: 1px solid rgba(12,76,12,0.3);
+ color: #3a5a1e;
+ white-space: nowrap;
+ &.malus { background: rgba(192,68,68,0.1); border-color: rgba(192,68,68,0.35); color: #922; }
+ }
+
+ .equip-toggle {
+ color: var(--cel-border);
+ &.equipped { color: var(--cel-green); }
+ &:hover { color: var(--cel-orange); }
+ }
+
+ .item-controls {
+ opacity: 1;
+ gap: 10px;
+ flex-shrink: 0;
+
+ a {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 20px;
+ min-height: 20px;
+ font-size: 1.08rem;
+ line-height: 1;
+ }
+ }
+ }
+
+ .equip-empty {
+ font-size: 0.85em;
+ font-style: italic;
+ color: var(--cel-border);
+ padding: 4px 8px;
+ }
+ }
+ }
+
+ .notes-section {
+ margin-bottom: 12px;
+ .section-header { .cel-section-header(); }
+ }
+
+ .description-section, .notes-section {
margin-top: 8px;
.enriched-html { font-size: 0.9em; line-height: 1.6; }
}
diff --git a/styles/roll.less b/styles/roll.less
index 5d7709d..9d8a185 100644
--- a/styles/roll.less
+++ b/styles/roll.less
@@ -285,6 +285,11 @@
.form-visibility label { color: #888; }
+ .form-faction-aspect select {
+ font-weight: bold;
+ color: var(--cel-green, #0c4c0c);
+ }
+
// ── Ligne Puiser dans ses ressources ──
.form-puiser-row {
border: 1px solid rgba(139,62,72,0.4);
@@ -581,6 +586,7 @@
.fl-mod.fortune { color: var(--cel-green, #0c4c0c); font-weight: bold; }
.fl-mod.wound { color: #922; }
.fl-asp { color: var(--cel-orange, #e07b00); font-weight: bold; }
+ .fl-faction { color: var(--cel-green, #0c4c0c); font-weight: bold; }
.fl-sep { font-weight: bold; color: var(--cel-border, #7a5c20); margin: 0 2px; }
.fl-eq { color: #aaa; }
.fl-op { color: #aaa; }
@@ -646,6 +652,116 @@
font-style: italic;
}
+ .used-info.used-faction-aspect {
+ color: var(--cel-green, #0c4c0c);
+ background: rgba(12,76,12,0.08);
+ border-top-color: rgba(12,76,12,0.3);
+ }
+
+ .weapon-damage-summary {
+ padding: 8px 12px 10px;
+ background: linear-gradient(180deg, rgba(224,123,0,0.08), rgba(224,123,0,0.02));
+ border-top: 1px solid rgba(122,92,32,0.25);
+ text-align: center;
+
+ &.is-applied {
+ background: linear-gradient(180deg, rgba(12,76,12,0.1), rgba(12,76,12,0.03));
+ }
+
+ &.is-pending {
+ background: linear-gradient(180deg, rgba(122,92,32,0.14), rgba(122,92,32,0.04));
+ }
+
+ .damage-header {
+ font-size: 0.72em;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--cel-border, #7a5c20);
+ opacity: 0.85;
+ }
+
+ .damage-main {
+ margin-top: 2px;
+ display: flex;
+ align-items: baseline;
+ justify-content: center;
+ gap: 6px;
+ }
+
+ .damage-value {
+ font-family: var(--cel-font-title, "CopaseticNF", serif);
+ font-size: 1.9em;
+ line-height: 1;
+ color: var(--cel-orange, #e07b00);
+ }
+
+ .damage-unit {
+ font-size: 0.82em;
+ color: var(--cel-green, #0c4c0c);
+ font-weight: bold;
+ }
+
+ .damage-breakdown,
+ .damage-note {
+ margin-top: 6px;
+ font-size: 0.76em;
+ line-height: 1.4;
+ color: #5c4630;
+ }
+
+ .weapon-damage-actions {
+ margin-top: 8px;
+ display: flex;
+ gap: 8px;
+ justify-content: center;
+ align-items: center;
+
+ select {
+ min-width: 170px;
+ border: 1px solid rgba(122,92,32,0.45);
+ border-radius: 3px;
+ padding: 3px 7px;
+ background: rgba(255,255,255,0.9);
+ }
+
+ .damage-apply-button {
+ border: 1px solid var(--cel-green, #0c4c0c);
+ border-radius: 4px;
+ background: var(--cel-green, #0c4c0c);
+ color: var(--cel-orange-light, #ddb84a);
+ padding: 4px 10px;
+ font-size: 0.78em;
+ font-weight: bold;
+ cursor: pointer;
+
+ &:disabled {
+ opacity: 0.7;
+ cursor: default;
+ }
+ }
+ }
+
+ .damage-application-status {
+ margin-top: 8px;
+ padding: 6px 8px;
+ border-radius: 4px;
+ font-size: 0.76em;
+ line-height: 1.4;
+
+ &.is-applied {
+ background: rgba(12,76,12,0.12);
+ border: 1px solid rgba(12,76,12,0.28);
+ color: var(--cel-green, #0c4c0c);
+ }
+
+ &.is-pending {
+ background: rgba(122,92,32,0.12);
+ border: 1px solid rgba(122,92,32,0.28);
+ color: #6b4b12;
+ }
+ }
+ }
+
// ── Fortune fixe badge dans zone dés ──
.fortune-fixed-badge {
display: inline-flex;
@@ -783,6 +899,298 @@
}
}
+.application.faction-aspect-dialog {
+ width: min(540px, 92vw);
+}
+
+.application.faction-aspect-dialog .window-content {
+ padding: 0 !important;
+ display: flex;
+ flex-direction: column;
+ max-height: min(70vh, 680px);
+ overflow: hidden;
+ background: var(--cel-cream, #f0e8d4);
+ border-top: 2px solid var(--cel-orange, #e07b00);
+
+ > form {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ flex: 1 1 auto;
+ }
+
+ .dialog-content.standard-form {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: 6px;
+ padding: 8px 10px 6px !important;
+ overflow-y: auto;
+ scrollbar-gutter: stable;
+ overscroll-behavior: contain;
+ min-height: 0;
+ flex: 1 1 auto;
+ align-content: start;
+ }
+
+ .form-footer {
+ display: flex;
+ flex: 0 0 auto;
+ flex-wrap: wrap;
+ gap: 5px;
+ padding: 6px 10px 8px;
+ margin: 0;
+ border-top: 1px solid rgba(122,92,32,0.18);
+ background: rgba(255,255,255,0.28);
+
+ button {
+ min-height: 28px;
+ padding: 3px 8px;
+ border: 1px solid rgba(122,92,32,0.35);
+ border-radius: 4px;
+ background: linear-gradient(180deg, rgba(12,76,12,0.1), rgba(12,76,12,0.03));
+ color: var(--cel-green, #0c4c0c);
+ font-family: var(--cel-font-title, "CopaseticNF", serif);
+ letter-spacing: 0.03em;
+
+ &:hover {
+ border-color: var(--cel-orange, #e07b00);
+ background: linear-gradient(180deg, rgba(224,123,0,0.18), rgba(224,123,0,0.06));
+ color: #7a3e00;
+ }
+ }
+ }
+
+ .faction-aspect-box {
+ border: 1px solid rgba(122,92,32,0.22);
+ border-radius: 4px;
+ background: linear-gradient(180deg, rgba(255,255,255,0.55), rgba(255,255,255,0.34));
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.55);
+ padding: 6px 7px;
+ min-width: 0;
+
+ &:first-child {
+ background: linear-gradient(180deg, rgba(12,76,12,0.08), rgba(255,255,255,0.42));
+ }
+ }
+
+ .faction-aspect-box-title {
+ margin-bottom: 4px;
+ color: var(--cel-green, #0c4c0c);
+ font-family: var(--cel-font-title, "CopaseticNF", serif);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-size: 0.88em;
+ }
+
+ .faction-aspect-points {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ margin-bottom: 4px;
+ font-size: 0.8em;
+ }
+
+ .faction-aspect-point-card {
+ display: inline-flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 92px;
+ padding: 4px 6px;
+ border-radius: 4px;
+ background: rgba(255,255,255,0.5);
+ border: 1px solid rgba(122,92,32,0.18);
+
+ strong {
+ font-size: 0.72em;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--cel-border, #7a5c20);
+ }
+
+ em {
+ font-style: normal;
+ font-family: var(--cel-font-title, "CopaseticNF", serif);
+ font-size: 1.05em;
+ color: var(--cel-orange, #e07b00);
+ line-height: 1;
+ }
+ }
+
+ .faction-aspect-source-line {
+ font-size: 0.78em;
+ color: var(--cel-border, #7a5c20);
+ }
+
+ .faction-aspect-warning {
+ padding: 7px 9px;
+ border-left: 3px solid #b84a2e;
+ border-radius: 4px;
+ background: rgba(184, 74, 46, 0.08);
+ color: #8b3e2b;
+ font-size: 0.8em;
+ }
+
+ .faction-aspect-tag-list,
+ .faction-aspect-source-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 3px 4px;
+ }
+
+ .faction-aspect-tag {
+ display: inline-flex;
+ align-items: center;
+ padding: 1px 6px;
+ border-radius: 999px;
+ background: rgba(12, 76, 12, 0.08);
+ border: 1px solid rgba(12, 76, 12, 0.18);
+ color: var(--cel-green, #0c4c0c);
+ font-size: 0.72em;
+ font-weight: bold;
+ }
+
+ .form-group {
+ margin-bottom: 4px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ label {
+ display: block;
+ margin-bottom: 2px;
+ font-size: 0.68em;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--cel-border, #7a5c20);
+ }
+
+ input[type="number"],
+ input[type="text"],
+ select {
+ width: 100%;
+ min-height: 28px;
+ border: 1px solid rgba(122,92,32,0.32);
+ border-radius: 3px;
+ padding: 2px 6px;
+ background: rgba(255,255,255,0.9);
+ font-size: 0.78em;
+ color: #2f2413;
+ box-sizing: border-box;
+ }
+
+ select option {
+ color: #2f2413;
+ background: #fffaf0;
+ }
+ }
+
+ .faction-aspect-pool-group {
+ margin-bottom: 6px;
+
+ input[type="number"] {
+ max-width: 180px;
+ font-weight: bold;
+ }
+ }
+
+ .faction-aspect-help-tip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 14px;
+ height: 14px;
+ margin-left: 4px;
+ border-radius: 50%;
+ border: 1px solid rgba(122,92,32,0.28);
+ background: rgba(224,123,0,0.12);
+ color: var(--cel-orange, #e07b00);
+ font-family: var(--cel-font-title, "CopaseticNF", serif);
+ font-size: 0.72em;
+ line-height: 1;
+ cursor: help;
+ vertical-align: middle;
+ }
+
+ .faction-aspect-cell-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 3px 6px;
+ }
+
+ .faction-aspect-advanced {
+ margin-top: 2px;
+ padding-top: 3px;
+ border-top: 1px dashed rgba(122,92,32,0.18);
+
+ summary {
+ cursor: pointer;
+ color: var(--cel-orange, #e07b00);
+ font-family: var(--cel-font-title, "CopaseticNF", serif);
+ margin-bottom: 4px;
+ font-size: 0.82em;
+ }
+ }
+
+ .faction-aspect-cell-option {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 0.74em;
+ }
+
+ .faction-aspect-active-list {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ }
+
+ .faction-aspect-active-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 6px;
+ padding: 3px 6px;
+ border-radius: 4px;
+ background: rgba(255,255,255,0.7);
+ color: #2f2413;
+ font-size: 0.76em;
+
+ &.is-relevant {
+ border-left: 3px solid var(--cel-green, #0c4c0c);
+ }
+ }
+
+ .faction-aspect-active-name {
+ color: #2f2413;
+ font-weight: 600;
+ }
+
+ .faction-aspect-active-value {
+ color: var(--cel-orange, #e07b00);
+ font-family: var(--cel-font-title, "CopaseticNF", serif);
+ font-size: 0.92em;
+ }
+
+ .faction-aspect-empty {
+ color: #666;
+ font-style: italic;
+ font-size: 0.74em;
+ }
+
+ .faction-aspect-remove-block {
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px solid rgba(122,92,32,0.18);
+ }
+
+ @media (max-width: 720px) {
+ .faction-aspect-cell-grid {
+ grid-template-columns: 1fr;
+ }
+ }
+}
+
// Notification de blessure cochée lors d'un test de résistance raté
.celestopol.chat-roll {
diff --git a/templates/armure.hbs b/templates/armure.hbs
index 136e43d..9bce44b 100644
--- a/templates/armure.hbs
+++ b/templates/armure.hbs
@@ -35,11 +35,8 @@
diff --git a/templates/character-equipement.hbs b/templates/character-equipement.hbs
index 49bca1a..1e869d2 100644
--- a/templates/character-equipement.hbs
+++ b/templates/character-equipement.hbs
@@ -45,7 +45,7 @@
{{item.name}}
{{item.system.protection}}
- {{#if item.system.malus}}−{{item.system.malus}} {{localize "CELESTOPOL.Armure.malus"}}{{/if}}
+ {{#if item.system.protection}}−{{item.system.protection}} {{localize "CELESTOPOL.Armure.malus"}}{{/if}}
-
-
-
- | {{localize "CELESTOPOL.Faction.label"}} |
- {{localize "CELESTOPOL.Faction.relation"}} |
-
-
-
- {{!-- Factions standard --}}
- {{#each factionRows as |faction|}}
-
- | {{localize faction.label}} |
-
-
-
- {{#each faction.dots as |dot|}}
-
- {{/each}}
-
- {{faction.valueStr}}
-
- |
-
- {{/each}}
+
+
+
- {{!-- Factions personnalisées --}}
- {{#each factionCustom as |faction|}}
-
- |
- {{#if @root.isEditMode}}
-
- {{else}}
- {{#if faction.label}}{{faction.label}}{{else}}—{{/if}}
- {{/if}}
- |
-
-
-
- {{#each faction.dots as |dot|}}
-
- {{/each}}
-
- {{faction.valueStr}}
+ {{#if factionAspectSummary}}
+
+
+ {{localize "CELESTOPOL.FactionAspect.pointsMax"}}
+ {{factionAspectSummary.pointsMax}}
+
+
+ {{localize "CELESTOPOL.FactionAspect.pointsSpent"}}
+ {{factionAspectSummary.pointsSpent}}
+
+
+ {{localize "CELESTOPOL.FactionAspect.pointsRemaining"}}
+ {{factionAspectSummary.pointsRemaining}}
+
+
+
+ {{#if factionAspectSummary.sourceLabels.length}}
+
+ {{localize "CELESTOPOL.FactionAspect.sources"}} :
+ {{#each factionAspectSummary.sourceLabels as |label|}}
+ {{label}}
+ {{/each}}
+
+ {{else}}
+ {{localize "CELESTOPOL.FactionAspect.officialSourcesEmpty"}}
+ {{/if}}
+
+
+ {{localize "CELESTOPOL.FactionAspect.activeTitle"}}
+ {{#if factionAspectSummary.activatedAspects.length}}
+
+ {{#each factionAspectSummary.activatedAspects as |aspect|}}
+
+ {{aspect.label}}
+ +{{aspect.value}}
- |
-
- {{/each}}
-
-
+ {{/each}}
+
+ {{else}}
+ {{localize "CELESTOPOL.FactionAspect.noneActive"}}
+ {{/if}}
+
+ {{/if}}
+
+
+
+
+
+ | {{localize "CELESTOPOL.Faction.label"}} |
+ {{localize "CELESTOPOL.Faction.relation"}} |
+
+
+
+ {{!-- Factions standard --}}
+ {{#each factionRows as |faction|}}
+
+ | {{localize faction.label}} |
+
+
+
+ {{#each faction.dots as |dot|}}
+
+ {{/each}}
+
+ {{faction.valueStr}}
+
+ |
+
+ {{/each}}
+
+ {{!-- Factions personnalisées --}}
+ {{#each factionCustom as |faction|}}
+
+ |
+ {{#if @root.isEditMode}}
+
+ {{else}}
+ {{#if faction.label}}{{faction.label}}{{else}}—{{/if}}
+ {{/if}}
+ |
+
+
+
+ {{#each faction.dots as |dot|}}
+
+ {{/each}}
+
+ {{faction.valueStr}}
+
+ |
+
+ {{/each}}
+
+
+
+
+
diff --git a/templates/character-main.hbs b/templates/character-main.hbs
index 3f41356..cda521c 100644
--- a/templates/character-main.hbs
+++ b/templates/character-main.hbs
@@ -45,10 +45,18 @@
- {{#if isEditMode}}
-
+ {{#if isEditMode}}
+
{{else}}
- {{system.faction}}
+ {{primaryFactionLabel}}
{{/if}}
@@ -92,6 +100,11 @@
{{/if}}
+ {{#if armorMalus}}
+
+ 🛡 {{localize "CELESTOPOL.Roll.armorMalus"}} (−{{abs armorMalus}})
+
+ {{/if}}
+ {{#if factionAspectBonus}}
+
+ ⚑ {{factionAspectLabel}} (+{{factionAspectBonus}})
+
+ {{/if}}
{{!-- Résultat du Dé de la Lune (narratif) --}}
{{#if hasMoonDie}}
@@ -136,7 +154,7 @@
{{#if isRangedDefense}}
{{localize "CELESTOPOL.Combat.rangedDefenseSuccess"}}
{{else}}
- {{localize "CELESTOPOL.Combat.successHit"}}{{#if (gt weaponDegats "0")}} +{{weaponDegats}} {{localize "CELESTOPOL.Combat.weaponDamage"}}{{/if}}
+ {{localize "CELESTOPOL.Combat.successHit"}}
{{/if}}
{{/if}}
{{else if isFailure}}
@@ -154,6 +172,47 @@
{{/if}}
+ {{#if hasDamageSummary}}
+
+
+
+ {{incomingWoundsDisplay}}
+ {{localize "CELESTOPOL.Combat.damageUnit"}}
+
+
+ {{#if hasVariableDamage}}
+
{{localize "CELESTOPOL.Combat.damageManual"}}
+ {{else}}
+ {{#if targetActorId}}
+
+
{{localize "CELESTOPOL.Combat.targetLabel"}} : {{targetActorName}}
+
{{localize "CELESTOPOL.Combat.damageArmorReduction"}} : −{{selectedTargetProtection}}
+
{{localize "CELESTOPOL.Combat.damageApplied"}} : {{selectedTargetAppliedWounds}}
+
+
+
+
+ {{else if availableTargets.length}}
+
+
+
+
+ {{else}}
+
{{localize "CELESTOPOL.Combat.noCharacterTargetAvailable"}}
+ {{/if}}
+ {{/if}}
+
+ {{/if}}
+
{{!-- Blessure auto-cochée (résistance ratée ou combat mêlée raté) --}}
{{#if woundTaken}}
diff --git a/templates/npc-equipement.hbs b/templates/npc-equipement.hbs
index 4c17461..b66abdd 100644
--- a/templates/npc-equipement.hbs
+++ b/templates/npc-equipement.hbs
@@ -39,7 +39,7 @@
{{item.name}}
{{item.system.protection}}
- {{#if item.system.malus}}
−{{item.system.malus}} {{localize "CELESTOPOL.Armure.malus"}}{{/if}}
+ {{#if item.system.protection}}
−{{item.system.protection}} {{localize "CELESTOPOL.Armure.malus"}}{{/if}}
diff --git a/templates/roll-dialog.hbs b/templates/roll-dialog.hbs
index 8a44666..babe84b 100644
--- a/templates/roll-dialog.hbs
+++ b/templates/roll-dialog.hbs
@@ -52,7 +52,7 @@
@@ -85,7 +85,6 @@
{{!-- Test en opposition : le résultat sera masqué, MJ décide --}}
- {{#unless isResistance}}
- {{/unless}}
{{/if}}
@@ -137,6 +135,18 @@
{{/unless}}{{!-- /isResistance aspect --}}
+ {{#if factionAspectChoices.length}}
+
+
+
+
+ {{/if}}
+
{{!-- Options non disponibles en test de résistance (lune, destin, puiser, fortune) --}}
{{#unless isResistance}}
@@ -225,5 +235,3 @@
-
-