feat: implémentation complète du système Célestopol 1922 pour FoundryVTT v13

- DataModels (character, npc, anomaly, aspect, attribute, equipment)
- ApplicationV2 sheets (character 5 tabs, npc 3 tabs, 4 item sheets)
- DialogV2 pour les jets de dés avec phase de lune
- Templates Handlebars complets (fiches PJ/PNJ, items, jet, chat)
- Styles LESS → CSS compilé (thème vert foncé / orange CopaseticNF)
- i18n fr.json complet (clés CELESTOPOL.*)
- Point d'entrée fvtt-celestopol.mjs avec hooks init/ready
- Assets : polices CopaseticNF, images UI, icônes items
- Mise à jour copilot-instructions.md avec l'architecture réelle

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-03-28 09:28:34 +01:00
parent 446f9b7500
commit 64e23271df
54 changed files with 3433 additions and 4 deletions

View File

@@ -0,0 +1,3 @@
export { default as CelestopolCharacterSheet } from "./sheets/character-sheet.mjs"
export { default as CelestopolNPCSheet } from "./sheets/npc-sheet.mjs"
export { CelestopolAnomalySheet, CelestopolAspectSheet, CelestopolAttributeSheet, CelestopolEquipmentSheet } from "./sheets/item-sheets.mjs"

View File

@@ -0,0 +1,126 @@
const { HandlebarsApplicationMixin } = foundry.applications.api
export default class CelestopolActorSheet extends HandlebarsApplicationMixin(foundry.applications.sheets.ActorSheetV2) {
static SHEET_MODES = { EDIT: 0, PLAY: 1 }
constructor(options = {}) {
super(options)
this.#dragDrop = this.#createDragDropHandlers()
}
#dragDrop
/** @override */
static DEFAULT_OPTIONS = {
classes: ["fvtt-celestopol", "actor"],
position: { width: 900, height: "auto" },
form: { submitOnChange: true },
window: { resizable: true },
dragDrop: [{ dragSelector: '[data-drag="true"], .rollable', dropSelector: null }],
actions: {
editImage: CelestopolActorSheet.#onEditImage,
toggleSheet: CelestopolActorSheet.#onToggleSheet,
edit: CelestopolActorSheet.#onItemEdit,
delete: CelestopolActorSheet.#onItemDelete,
},
}
_sheetMode = this.constructor.SHEET_MODES.PLAY
get isPlayMode() { return this._sheetMode === this.constructor.SHEET_MODES.PLAY }
get isEditMode() { return this._sheetMode === this.constructor.SHEET_MODES.EDIT }
/** @override */
async _prepareContext() {
return {
fields: this.document.schema.fields,
systemFields: this.document.system.schema.fields,
actor: this.document,
system: this.document.system,
source: this.document.toObject(),
isEditMode: this.isEditMode,
isPlayMode: this.isPlayMode,
isEditable: this.isEditable,
}
}
/** @override */
_onRender(context, options) {
this.#dragDrop.forEach(d => d.bind(this.element))
this.element.querySelectorAll(".rollable").forEach(el => {
el.addEventListener("click", this._onRoll.bind(this))
})
}
async _onRoll(event) {
if (!this.isPlayMode) return
const el = event.currentTarget
const statId = el.dataset.statId
const skillId = el.dataset.skillId
if (!statId || !skillId) return
await this.document.system.roll(statId, skillId)
}
#createDragDropHandlers() {
return this.options.dragDrop.map(d => {
d.permissions = {
dragstart: this._canDragStart.bind(this),
drop: this._canDragDrop.bind(this),
}
d.callbacks = {
dragover: this._onDragOver.bind(this),
drop: this._onDrop.bind(this),
}
return new foundry.applications.ux.DragDrop.implementation(d)
})
}
_canDragStart() { return this.isEditable }
_canDragDrop() { return true }
_onDragOver() {}
async _onDrop(event) {
if (!this.isEditable) return
const data = foundry.applications.ux.TextEditor.implementation.getDragEventData(event)
if (data.type === "Item") {
const item = await fromUuid(data.uuid)
if (item) return this._onDropItem(item)
}
}
async _onDropItem(item) {
await this.document.createEmbeddedDocuments("Item", [item.toObject()], { renderSheet: false })
}
static async #onEditImage(event, target) {
const attr = target.dataset.edit
const current = foundry.utils.getProperty(this.document, attr)
const fp = new FilePicker({
current,
type: "image",
callback: (path) => this.document.update({ [attr]: path }),
top: this.position.top + 40,
left: this.position.left + 10,
})
return fp.browse()
}
static #onToggleSheet() {
const modes = this.constructor.SHEET_MODES
this._sheetMode = this.isEditMode ? modes.PLAY : modes.EDIT
this.render()
}
static async #onItemEdit(event, target) {
const uuid = target.getAttribute("data-item-uuid")
const id = target.getAttribute("data-item-id")
const item = uuid ? await fromUuid(uuid) : this.document.items.get(id)
item?.sheet.render(true)
}
static async #onItemDelete(event, target) {
const uuid = target.getAttribute("data-item-uuid")
const item = await fromUuid(uuid)
await item?.deleteDialog()
}
}

View File

@@ -0,0 +1,39 @@
const { HandlebarsApplicationMixin } = foundry.applications.api
export default class CelestopolItemSheet extends HandlebarsApplicationMixin(foundry.applications.sheets.ItemSheetV2) {
/** @override */
static DEFAULT_OPTIONS = {
classes: ["fvtt-celestopol", "item"],
position: { width: 580, height: "auto" },
form: { submitOnChange: true },
window: { resizable: true },
actions: {
editImage: CelestopolItemSheet.#onEditImage,
},
}
/** @override */
async _prepareContext() {
return {
fields: this.document.schema.fields,
systemFields: this.document.system.schema.fields,
item: this.document,
system: this.document.system,
source: this.document.toObject(),
isEditable: this.isEditable,
}
}
static async #onEditImage(event, target) {
const attr = target.dataset.edit
const current = foundry.utils.getProperty(this.document, attr)
const fp = new FilePicker({
current,
type: "image",
callback: (path) => this.document.update({ [attr]: path }),
top: this.position.top + 40,
left: this.position.left + 10,
})
return fp.browse()
}
}

View File

@@ -0,0 +1,116 @@
import CelestopolActorSheet from "./base-actor-sheet.mjs"
import { SYSTEM } from "../../config/system.mjs"
export default class CelestopolCharacterSheet extends CelestopolActorSheet {
/** @override */
static DEFAULT_OPTIONS = {
classes: ["character"],
position: { width: 920, height: 660 },
window: { contentClasses: ["character-content"] },
actions: {
createAnomaly: CelestopolCharacterSheet.#onCreateAnomaly,
createAspect: CelestopolCharacterSheet.#onCreateAspect,
createAttribute: CelestopolCharacterSheet.#onCreateAttribute,
createEquipment: CelestopolCharacterSheet.#onCreateEquipment,
},
}
/** @override */
static PARTS = {
main: { template: "systems/fvtt-celestopol/templates/character-main.hbs" },
tabs: { template: "templates/generic/tab-navigation.hbs" },
competences:{ template: "systems/fvtt-celestopol/templates/character-competences.hbs" },
blessures: { template: "systems/fvtt-celestopol/templates/character-blessures.hbs" },
factions: { template: "systems/fvtt-celestopol/templates/character-factions.hbs" },
biography: { template: "systems/fvtt-celestopol/templates/character-biography.hbs" },
}
tabGroups = { sheet: "competences" }
#getTabs() {
const tabs = {
competences:{ id: "competences", group: "sheet", icon: "fa-solid fa-dice-d6", label: "CELESTOPOL.Tab.competences" },
blessures: { id: "blessures", group: "sheet", icon: "fa-solid fa-heart-crack", label: "CELESTOPOL.Tab.blessures" },
factions: { id: "factions", group: "sheet", icon: "fa-solid fa-flag", label: "CELESTOPOL.Tab.factions" },
biography: { id: "biography", group: "sheet", icon: "fa-solid fa-book", label: "CELESTOPOL.Tab.biography" },
}
for (const v of Object.values(tabs)) {
v.active = this.tabGroups[v.group] === v.id
v.cssClass = v.active ? "active" : ""
}
return tabs
}
/** @override */
async _prepareContext() {
const context = await super._prepareContext()
context.tabs = this.#getTabs()
context.stats = SYSTEM.STATS
context.skills = SYSTEM.SKILLS
context.anomalyTypes = SYSTEM.ANOMALY_TYPES
context.factions = SYSTEM.FACTIONS
context.woundLevels = SYSTEM.WOUND_LEVELS
return context
}
/** @override */
async _preparePartContext(partId, context) {
context.systemFields = this.document.system.schema.fields
const doc = this.document
switch (partId) {
case "main":
break
case "competences":
context.tab = context.tabs.competences
context.anomalies = doc.itemTypes.anomaly
context.aspects = doc.itemTypes.aspect
context.attributes = doc.itemTypes.attribute
break
case "blessures":
context.tab = context.tabs.blessures
break
case "factions":
context.tab = context.tabs.factions
break
case "biography":
context.tab = context.tabs.biography
context.equipments = doc.itemTypes.equipment
context.equipments.sort((a, b) => a.name.localeCompare(b.name))
context.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
doc.system.description, { async: true })
context.enrichedNotes = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
doc.system.notes, { async: true })
break
}
return context
}
static #onCreateAnomaly() {
this.document.createEmbeddedDocuments("Item", [{
name: game.i18n.localize("CELESTOPOL.Item.newAnomaly"), type: "anomaly",
}])
}
static #onCreateAspect() {
this.document.createEmbeddedDocuments("Item", [{
name: game.i18n.localize("CELESTOPOL.Item.newAspect"), type: "aspect",
}])
}
static #onCreateAttribute() {
this.document.createEmbeddedDocuments("Item", [{
name: game.i18n.localize("CELESTOPOL.Item.newAttribute"), type: "attribute",
}])
}
static #onCreateEquipment() {
this.document.createEmbeddedDocuments("Item", [{
name: game.i18n.localize("CELESTOPOL.Item.newEquipment"), type: "equipment",
}])
}
}

View File

@@ -0,0 +1,83 @@
import CelestopolItemSheet from "./base-item-sheet.mjs"
import { SYSTEM } from "../../config/system.mjs"
export class CelestopolAnomalySheet extends CelestopolItemSheet {
static DEFAULT_OPTIONS = {
classes: ["anomaly"],
position: { width: 620, height: 560 },
}
static PARTS = {
main: { template: "systems/fvtt-celestopol/templates/anomaly.hbs" },
}
async _prepareContext() {
const ctx = await super._prepareContext()
ctx.anomalyTypes = SYSTEM.ANOMALY_TYPES
ctx.skills = SYSTEM.SKILLS
ctx.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.description, { async: true })
ctx.enrichedTechnique = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.technique, { async: true })
ctx.enrichedNarratif = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.narratif, { async: true })
return ctx
}
}
export class CelestopolAspectSheet extends CelestopolItemSheet {
static DEFAULT_OPTIONS = {
classes: ["aspect"],
position: { width: 620, height: 520 },
}
static PARTS = {
main: { template: "systems/fvtt-celestopol/templates/aspect.hbs" },
}
async _prepareContext() {
const ctx = await super._prepareContext()
ctx.skills = SYSTEM.SKILLS
ctx.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.description, { async: true })
ctx.enrichedTechnique = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.technique, { async: true })
ctx.enrichedNarratif = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.narratif, { async: true })
return ctx
}
}
export class CelestopolAttributeSheet extends CelestopolItemSheet {
static DEFAULT_OPTIONS = {
classes: ["attribute"],
position: { width: 620, height: 520 },
}
static PARTS = {
main: { template: "systems/fvtt-celestopol/templates/attribute.hbs" },
}
async _prepareContext() {
const ctx = await super._prepareContext()
ctx.skills = SYSTEM.SKILLS
ctx.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.description, { async: true })
ctx.enrichedTechnique = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.technique, { async: true })
ctx.enrichedNarratif = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.narratif, { async: true })
return ctx
}
}
export class CelestopolEquipmentSheet extends CelestopolItemSheet {
static DEFAULT_OPTIONS = {
classes: ["equipment"],
position: { width: 540, height: 420 },
}
static PARTS = {
main: { template: "systems/fvtt-celestopol/templates/equipment.hbs" },
}
async _prepareContext() {
const ctx = await super._prepareContext()
ctx.equipmentTypes = SYSTEM.EQUIPMENT_TYPES
ctx.enrichedDescription = await foundry.applications.ux.TextEditor.implementation.enrichHTML(
this.document.system.description, { async: true })
return ctx
}
}

View File

@@ -0,0 +1,57 @@
import CelestopolActorSheet from "./base-actor-sheet.mjs"
import { SYSTEM } from "../../config/system.mjs"
export default class CelestopolNPCSheet extends CelestopolActorSheet {
/** @override */
static DEFAULT_OPTIONS = {
classes: ["npc"],
position: { width: 760, height: 600 },
window: { contentClasses: ["npc-content"] },
}
/** @override */
static PARTS = {
main: { template: "systems/fvtt-celestopol/templates/npc-main.hbs" },
tabs: { template: "templates/generic/tab-navigation.hbs" },
competences:{ template: "systems/fvtt-celestopol/templates/npc-competences.hbs" },
blessures: { template: "systems/fvtt-celestopol/templates/npc-blessures.hbs" },
}
tabGroups = { sheet: "competences" }
#getTabs() {
const tabs = {
competences:{ id: "competences", group: "sheet", icon: "fa-solid fa-dice-d6", label: "CELESTOPOL.Tab.competences" },
blessures: { id: "blessures", group: "sheet", icon: "fa-solid fa-heart-crack", label: "CELESTOPOL.Tab.blessures" },
}
for (const v of Object.values(tabs)) {
v.active = this.tabGroups[v.group] === v.id
v.cssClass = v.active ? "active" : ""
}
return tabs
}
/** @override */
async _prepareContext() {
const context = await super._prepareContext()
context.tabs = this.#getTabs()
context.stats = SYSTEM.STATS
context.skills = SYSTEM.SKILLS
context.woundLevels = SYSTEM.WOUND_LEVELS
return context
}
/** @override */
async _preparePartContext(partId, context) {
context.systemFields = this.document.system.schema.fields
switch (partId) {
case "competences":
context.tab = context.tabs.competences
break
case "blessures":
context.tab = context.tabs.blessures
break
}
return context
}
}

130
module/config/system.mjs Normal file
View File

@@ -0,0 +1,130 @@
export const SYSTEM_ID = "fvtt-celestopol"
export const ASCII = `
░█▀▀░█▀▀░█░░░█▀▀░█▀▀░▀█▀░█▀█░█▀█░█▀█░█░░
░█░░░█▀▀░█░░░█▀▀░▀▀█░░█░░█░█░█▀▀░█░█░█░░
░▀▀▀░▀▀▀░▀▀▀░▀▀▀░▀▀▀░░▀░░▀▀▀░▀░░░▀▀▀░▀▀▀
░░░░░░░░░░░░░░░░░░1922░░░░░░░░░░░░░░░░░░░
`
/** Les 4 attributs principaux (stats). Chacun a une résistance (res) et 4 compétences. */
export const STATS = {
ame: { id: "ame", label: "CELESTOPOL.Stat.ame" },
corps: { id: "corps", label: "CELESTOPOL.Stat.corps" },
coeur: { id: "coeur", label: "CELESTOPOL.Stat.coeur" },
esprit: { id: "esprit", label: "CELESTOPOL.Stat.esprit" },
}
/** Compétences groupées par attribut. */
export const SKILLS = {
ame: {
artifice: { id: "artifice", label: "CELESTOPOL.Skill.artifice", stat: "ame" },
attraction: { id: "attraction", label: "CELESTOPOL.Skill.attraction", stat: "ame" },
coercition: { id: "coercition", label: "CELESTOPOL.Skill.coercition", stat: "ame" },
faveur: { id: "faveur", label: "CELESTOPOL.Skill.faveur", stat: "ame" },
},
corps: {
echauffouree: { id: "echauffouree", label: "CELESTOPOL.Skill.echauffouree", stat: "corps" },
effacement: { id: "effacement", label: "CELESTOPOL.Skill.effacement", stat: "corps" },
mobilite: { id: "mobilite", label: "CELESTOPOL.Skill.mobilite", stat: "corps" },
prouesse: { id: "prouesse", label: "CELESTOPOL.Skill.prouesse", stat: "corps" },
},
coeur: {
appreciation: { id: "appreciation", label: "CELESTOPOL.Skill.appreciation", stat: "coeur" },
arts: { id: "arts", label: "CELESTOPOL.Skill.arts", stat: "coeur" },
inspiration: { id: "inspiration", label: "CELESTOPOL.Skill.inspiration", stat: "coeur" },
traque: { id: "traque", label: "CELESTOPOL.Skill.traque", stat: "coeur" },
},
esprit: {
instruction: { id: "instruction", label: "CELESTOPOL.Skill.instruction", stat: "esprit" },
mtechnologique: { id: "mtechnologique", label: "CELESTOPOL.Skill.mtechnologique", stat: "esprit" },
raisonnement: { id: "raisonnement", label: "CELESTOPOL.Skill.raisonnement", stat: "esprit" },
traitement: { id: "traitement", label: "CELESTOPOL.Skill.traitement", stat: "esprit" },
},
}
/** Liste plate de toutes les compétences (utile pour les DataModels d'items). */
export const ALL_SKILLS = Object.values(SKILLS).flatMap(group => Object.values(group))
/** Types d'anomalies (pouvoirs paranormaux). */
export const ANOMALY_TYPES = {
none: { id: "none", label: "CELESTOPOL.Anomaly.none" },
entropie: { id: "entropie", label: "CELESTOPOL.Anomaly.entropie" },
communicationaveclesmorts:{ id: "communicationaveclesmorts",label: "CELESTOPOL.Anomaly.communicationaveclesmorts" },
illusion: { id: "illusion", label: "CELESTOPOL.Anomaly.illusion" },
suggestion: { id: "suggestion", label: "CELESTOPOL.Anomaly.suggestion" },
tarotdivinatoire: { id: "tarotdivinatoire", label: "CELESTOPOL.Anomaly.tarotdivinatoire" },
telekinesie: { id: "telekinesie", label: "CELESTOPOL.Anomaly.telekinesie" },
telepathie: { id: "telepathie", label: "CELESTOPOL.Anomaly.telepathie" },
voyageastral: { id: "voyageastral", label: "CELESTOPOL.Anomaly.voyageastral" },
}
/** Factions du monde de Célestopol. */
export const FACTIONS = {
pinkerton: { id: "pinkerton", label: "CELESTOPOL.Faction.pinkerton" },
police: { id: "police", label: "CELESTOPOL.Faction.police" },
okhrana: { id: "okhrana", label: "CELESTOPOL.Faction.okhrana" },
lunanovatek: { id: "lunanovatek", label: "CELESTOPOL.Faction.lunanovatek" },
oto: { id: "oto", label: "CELESTOPOL.Faction.oto" },
syndicats: { id: "syndicats", label: "CELESTOPOL.Faction.syndicats" },
vorovskoymir:{ id: "vorovskoymir",label: "CELESTOPOL.Faction.vorovskoymir" },
cour: { id: "cour", label: "CELESTOPOL.Faction.cour" },
}
/** Niveaux de blessures avec leur malus associé. */
export const WOUND_LEVELS = [
{ id: 0, label: "CELESTOPOL.Wound.none", malus: 0 },
{ id: 1, label: "CELESTOPOL.Wound.anodin", malus: 0 },
{ id: 2, label: "CELESTOPOL.Wound.derisoire", malus: 0 },
{ id: 3, label: "CELESTOPOL.Wound.negligeable", malus: -1 },
{ id: 4, label: "CELESTOPOL.Wound.superficiel", malus: -1 },
{ id: 5, label: "CELESTOPOL.Wound.leger", malus: -2 },
{ id: 6, label: "CELESTOPOL.Wound.modere", malus: -2 },
{ id: 7, label: "CELESTOPOL.Wound.grave", malus: -3 },
{ id: 8, label: "CELESTOPOL.Wound.dramatique", malus: -999 },
]
/** Seuils de difficulté pour les jets de dés. */
export const DIFFICULTY_CHOICES = {
unknown: { id: "unknown", label: "CELESTOPOL.Difficulty.unknown", value: 0 },
facile: { id: "facile", label: "CELESTOPOL.Difficulty.facile", value: 5 },
normal: { id: "normal", label: "CELESTOPOL.Difficulty.normal", value: 7 },
difficile:{ id: "difficile", label: "CELESTOPOL.Difficulty.difficile", value: 9 },
ardu: { id: "ardu", label: "CELESTOPOL.Difficulty.ardu", value: 11 },
extreme: { id: "extreme", label: "CELESTOPOL.Difficulty.extreme", value: 13 },
}
/** Phases de la lune (dé de lune). */
export const MOON_DICE_PHASES = {
none: { id: "none", label: "CELESTOPOL.Moon.none", bonus: 0 },
nouvellelune: { id: "nouvellelune", label: "CELESTOPOL.Moon.nouvellelune", bonus: 0 },
premiercroissant: { id: "premiercroissant", label: "CELESTOPOL.Moon.premiercroissant", bonus: 1 },
premierquartier: { id: "premierquartier", label: "CELESTOPOL.Moon.premierquartier", bonus: 1 },
lunegibbeuse: { id: "lunegibbeuse", label: "CELESTOPOL.Moon.lunegibbeuse", bonus: 2 },
lunevoutee: { id: "lunevoutee", label: "CELESTOPOL.Moon.lunevoutee", bonus: 2 },
derniercroissant: { id: "derniercroissant", label: "CELESTOPOL.Moon.derniercroissant", bonus: 1 },
dernierquartier: { id: "dernierquartier", label: "CELESTOPOL.Moon.dernierquartier", bonus: 1 },
pleinelune: { id: "pleinelune", label: "CELESTOPOL.Moon.pleinelune", bonus: 3 },
}
/** Types d'équipements. */
export const EQUIPMENT_TYPES = {
autre: { id: "autre", label: "CELESTOPOL.Equipment.autre" },
arme: { id: "arme", label: "CELESTOPOL.Equipment.arme" },
protection:{ id: "protection",label: "CELESTOPOL.Equipment.protection" },
vehicule: { id: "vehicule", label: "CELESTOPOL.Equipment.vehicule" },
}
export const SYSTEM = {
id: SYSTEM_ID,
ASCII,
STATS,
SKILLS,
ALL_SKILLS,
ANOMALY_TYPES,
FACTIONS,
WOUND_LEVELS,
DIFFICULTY_CHOICES,
MOON_DICE_PHASES,
EQUIPMENT_TYPES,
}

View File

@@ -0,0 +1,4 @@
export { default as CelestopolActor } from "./actor.mjs"
export { default as CelestopolItem } from "./item.mjs"
export { default as CelestopolChatMessage } from "./chat-message.mjs"
export { CelestopolRoll } from "./roll.mjs"

View File

@@ -0,0 +1,12 @@
export default class CelestopolActor extends Actor {
/** @override */
prepareDerivedData() {
super.prepareDerivedData()
this.system.prepareDerivedData?.()
}
/** @override */
getRollData() {
return this.toObject(false).system
}
}

View File

@@ -0,0 +1,7 @@
export default class CelestopolChatMessage extends ChatMessage {
/** @override */
async getHTML() {
const html = await super.getHTML()
return html
}
}

11
module/documents/item.mjs Normal file
View File

@@ -0,0 +1,11 @@
export default class CelestopolItem extends Item {
/** @override */
prepareDerivedData() {
super.prepareDerivedData()
}
/** @override */
getRollData() {
return this.toObject(false).system
}
}

170
module/documents/roll.mjs Normal file
View File

@@ -0,0 +1,170 @@
import { SYSTEM } from "../config/system.mjs"
/**
* Système de dés de Célestopol 1922.
*
* Le jet de base est : (valeur compétence)d6 comparé à un seuil de difficulté.
* Le dé de lune ajoute un bonus selon la phase actuelle.
* Destin et Spleen modifient le nombre de dés.
*/
export class CelestopolRoll extends Roll {
static CHAT_TEMPLATE = "systems/fvtt-celestopol/templates/chat-message.hbs"
get resultType() { return this.options.resultType }
get isSuccess() { return this.resultType === "success" }
get isFailure() { return this.resultType === "failure" }
get actorId() { return this.options.actorId }
get actorName() { return this.options.actorName }
get actorImage() { return this.options.actorImage }
get skillLabel() { return this.options.skillLabel }
get difficulty() { return this.options.difficulty }
get moonBonus() { return this.options.moonBonus ?? 0 }
/**
* Ouvre le dialogue de configuration du jet via DialogV2 et exécute le jet.
* @param {object} options
* @returns {Promise<CelestopolRoll|null>}
*/
static async prompt(options = {}) {
const rollModes = foundry.utils.duplicate(CONFIG.Dice.rollModes)
const fieldRollMode = new foundry.data.fields.StringField({
choices: rollModes,
blank: false,
default: "publicroll",
})
const dialogContext = {
actorName: options.actorName,
skillLabel: options.skillLabel,
skillValue: options.skillValue,
woundMalus: options.woundMalus ?? 0,
difficultyChoices:SYSTEM.DIFFICULTY_CHOICES,
moonPhaseChoices: SYSTEM.MOON_DICE_PHASES,
defaultDifficulty:options.difficulty ?? "normal",
defaultMoonPhase: options.moonPhase ?? "none",
rollModes,
fieldRollMode,
}
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-celestopol/templates/roll-dialog.hbs",
dialogContext
)
const title = `${game.i18n.localize("CELESTOPOL.Roll.title")}${game.i18n.localize(options.skillLabel ?? "")}`
const rollContext = await foundry.applications.api.DialogV2.wait({
window: { title },
classes: ["fvtt-celestopol", "roll-dialog"],
content,
buttons: [
{
label: game.i18n.localize("CELESTOPOL.Roll.roll"),
callback: (event, button) => {
return Array.from(button.form.elements).reduce((obj, input) => {
if (input.name) obj[input.name] = input.value
return obj
}, {})
},
},
],
rejectClose: false,
})
if (!rollContext) return null
const difficulty = rollContext.difficulty ?? "normal"
const diffConfig = SYSTEM.DIFFICULTY_CHOICES[difficulty] ?? SYSTEM.DIFFICULTY_CHOICES.normal
const moonPhase = rollContext.moonPhase ?? "none"
const moonConfig = SYSTEM.MOON_DICE_PHASES[moonPhase] ?? SYSTEM.MOON_DICE_PHASES.none
const modifier = parseInt(rollContext.modifier ?? 0) || 0
const woundMalus = options.woundMalus ?? 0
const skillValue = Math.max(0, (options.skillValue ?? 0) + woundMalus)
const nbDice = Math.max(1, skillValue)
const moonBonus = moonConfig.bonus ?? 0
const totalModifier = moonBonus + modifier
const formula = totalModifier !== 0
? `${nbDice}d6 + ${totalModifier}`
: `${nbDice}d6`
const rollData = {
...options,
difficulty,
difficultyValue: diffConfig.value,
moonPhase,
moonBonus,
modifier,
formula,
rollMode: rollContext.visibility ?? "publicroll",
}
const roll = new this(formula, {}, rollData)
await roll.evaluate()
roll.computeResult()
await roll.toMessage({}, { rollMode: rollData.rollMode })
// Mémoriser les préférences sur l'acteur
const actor = game.actors.get(options.actorId)
if (actor) {
await actor.update({
"system.prefs.moonPhase": moonPhase,
"system.prefs.difficulty": difficulty,
})
}
return roll
}
/** Détermine succès/échec selon le total vs le seuil. */
computeResult() {
const threshold = SYSTEM.DIFFICULTY_CHOICES[this.options.difficulty]?.value ?? 0
if (threshold === 0) {
this.options.resultType = "unknown"
} else if (this.total >= threshold) {
this.options.resultType = "success"
} else {
this.options.resultType = "failure"
}
}
/** @override */
async render(chatOptions = {}) {
const data = await this._getChatCardData(chatOptions.isPrivate)
return foundry.applications.handlebars.renderTemplate(this.constructor.CHAT_TEMPLATE, data)
}
async _getChatCardData(isPrivate) {
return {
css: [SYSTEM.id, "dice-roll"],
cssClass: [SYSTEM.id, "dice-roll"].join(" "),
actorId: this.actorId,
actingCharName: this.actorName,
actingCharImg: this.actorImage,
skillLabel: this.skillLabel,
formula: this.formula,
total: this.total,
resultType: this.resultType,
isSuccess: this.isSuccess,
isFailure: this.isFailure,
difficulty: this.options.difficulty,
difficultyValue:this.options.difficultyValue,
moonPhase: this.options.moonPhase,
moonBonus: this.moonBonus,
isPrivate,
tooltip: isPrivate ? "" : await this.getTooltip(),
results: this.dice[0]?.results ?? [],
}
}
/** @override */
async toMessage(messageData = {}, { rollMode, create = true } = {}) {
return super.toMessage(
{
flavor: `<strong>${game.i18n.localize(this.skillLabel ?? "")}</strong>`,
...messageData,
},
{ rollMode }
)
}
}

View File

@@ -0,0 +1,3 @@
export { default as CelestopolCharacter } from "./character.mjs"
export { default as CelestopolNPC } from "./npc.mjs"
export { CelestopolAnomaly, CelestopolAspect, CelestopolAttribute, CelestopolEquipment } from "./items.mjs"

175
module/models/character.mjs Normal file
View File

@@ -0,0 +1,175 @@
import { SYSTEM } from "../config/system.mjs"
export default class CelestopolCharacter extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
const schema = {}
// Concept du personnage
schema.concept = new fields.StringField({ required: true, nullable: false, initial: "" })
// Initiative (calculée mais stockée pour affichage)
schema.initiative = new fields.NumberField({ ...reqInt, initial: 0, min: 0 })
// Anomalie du personnage
schema.anomaly = new fields.SchemaField({
type: new fields.StringField({ required: true, nullable: false, initial: "none",
choices: Object.keys(SYSTEM.ANOMALY_TYPES) }),
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
})
// Les 4 stats avec leurs compétences
const skillField = (label) => new fields.SchemaField({
label: new fields.StringField({ required: true, initial: label }),
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
})
const statField = (statId) => {
const skills = SYSTEM.SKILLS[statId]
const skillSchema = {}
for (const [key, skill] of Object.entries(skills)) {
skillSchema[key] = skillField(skill.label)
}
return new fields.SchemaField({
label: new fields.StringField({ required: true, initial: SYSTEM.STATS[statId].label }),
res: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
...skillSchema,
})
}
schema.stats = new fields.SchemaField({
ame: statField("ame"),
corps: statField("corps"),
coeur: statField("coeur"),
esprit: statField("esprit"),
})
// Blessures (8 cases)
const woundField = (idx) => new fields.SchemaField({
checked: new fields.BooleanField({ required: true, initial: false }),
malus: new fields.NumberField({ ...reqInt, initial: SYSTEM.WOUND_LEVELS[idx]?.malus ?? 0 }),
})
schema.blessures = new fields.SchemaField({
lvl: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
b1: woundField(1), b2: woundField(2), b3: woundField(3), b4: woundField(4),
b5: woundField(5), b6: woundField(6), b7: woundField(7), b8: woundField(8),
})
// Destin (8 cases)
const destField = () => new fields.SchemaField({
checked: new fields.BooleanField({ required: true, initial: false }),
})
schema.destin = new fields.SchemaField({
lvl: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
d1: destField(), d2: destField(), d3: destField(), d4: destField(),
d5: destField(), d6: destField(), d7: destField(), d8: destField(),
})
// Spleen (8 cases)
schema.spleen = new fields.SchemaField({
lvl: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
s1: destField(), s2: destField(), s3: destField(), s4: destField(),
s5: destField(), s6: destField(), s7: destField(), s8: destField(),
})
// Attributs de personnage (Entregent, Fortune, Rêve, Vision)
const persoAttrField = () => new fields.SchemaField({
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }),
max: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }),
})
schema.attributs = new fields.SchemaField({
entregent: persoAttrField(),
fortune: persoAttrField(),
reve: persoAttrField(),
vision: persoAttrField(),
})
// Factions
const factionField = () => new fields.SchemaField({
value: new fields.NumberField({ ...reqInt, initial: 0 }),
})
schema.factions = new fields.SchemaField({
pinkerton: factionField(),
police: factionField(),
okhrana: factionField(),
lunanovatek: factionField(),
oto: factionField(),
syndicats: factionField(),
vorovskoymir: factionField(),
cour: factionField(),
perso1: new fields.SchemaField({
label: new fields.StringField({ required: true, nullable: false, initial: "" }),
value: new fields.NumberField({ ...reqInt, initial: 0 }),
}),
perso2: new fields.SchemaField({
label: new fields.StringField({ required: true, nullable: false, initial: "" }),
value: new fields.NumberField({ ...reqInt, initial: 0 }),
}),
})
// Préférences de jet (mémorisé entre sessions)
schema.prefs = new fields.SchemaField({
moonPhase: new fields.StringField({ required: true, nullable: false, initial: "none" }),
difficulty: new fields.StringField({ required: true, nullable: false, initial: "normal" }),
})
// Description & notes
schema.description = new fields.HTMLField({ required: true, textSearch: true })
schema.notes = new fields.HTMLField({ required: true, textSearch: true })
// Données biographiques
schema.biodata = new fields.SchemaField({
age: new fields.StringField({ required: true, nullable: false, initial: "" }),
genre: new fields.StringField({ required: true, nullable: false, initial: "" }),
taille: new fields.StringField({ required: true, nullable: false, initial: "" }),
yeux: new fields.StringField({ required: true, nullable: false, initial: "" }),
naissance: new fields.StringField({ required: true, nullable: false, initial: "" }),
cheveux: new fields.StringField({ required: true, nullable: false, initial: "" }),
origine: new fields.StringField({ required: true, nullable: false, initial: "" }),
})
return schema
}
static LOCALIZATION_PREFIXES = ["CELESTOPOL.Character"]
prepareDerivedData() {
super.prepareDerivedData()
// L'initiative est basée sur la résistance Corps
this.initiative = this.stats.corps.res
}
/**
* Calcule le malus de blessures actif.
* @returns {number}
*/
getWoundMalus() {
const lvl = Math.max(0, Math.min(8, this.blessures.lvl))
return SYSTEM.WOUND_LEVELS[lvl]?.malus ?? 0
}
/**
* Lance les dés pour une compétence donnée.
* @param {string} statId - Id de la stat (ame, corps, coeur, esprit)
* @param {string} skillId - Id de la compétence
*/
async roll(statId, skillId) {
const { CelestopolRoll } = await import("../documents/roll.mjs")
const skill = this.stats[statId][skillId]
if (!skill) return null
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorName: this.parent.name,
actorImage: this.parent.img,
statId,
skillId,
skillLabel: skill.label,
skillValue: skill.value,
woundMalus: this.getWoundMalus(),
moonPhase: this.prefs.moonPhase,
difficulty: this.prefs.difficulty,
})
}
}

98
module/models/items.mjs Normal file
View File

@@ -0,0 +1,98 @@
import { SYSTEM } from "../config/system.mjs"
/** Schéma partagé pour les bonus/malus par compétence (utilisé dans anomaly/aspect/attribute). */
function skillScoresSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
const scoreField = () => new fields.SchemaField({
bonus: new fields.NumberField({ ...reqInt, initial: 0 }),
malus: new fields.NumberField({ ...reqInt, initial: 0 }),
})
const statGroup = (statId) => {
const skills = SYSTEM.SKILLS[statId]
const schema = {}
for (const key of Object.keys(skills)) {
schema[key] = scoreField()
}
return new fields.SchemaField(schema)
}
return new fields.SchemaField({
ame: statGroup("ame"),
corps: statGroup("corps"),
coeur: statGroup("coeur"),
esprit: statGroup("esprit"),
})
}
export class CelestopolAnomaly extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
return {
subtype: new fields.StringField({ required: true, nullable: false, initial: "none",
choices: Object.keys(SYSTEM.ANOMALY_TYPES) }),
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
reference: new fields.StringField({ required: true, nullable: false, initial: "" }),
scores: skillScoresSchema(),
description: new fields.HTMLField({ required: true, textSearch: true }),
technique: new fields.HTMLField({ required: true, textSearch: true }),
narratif: new fields.HTMLField({ required: true, textSearch: true }),
notes: new fields.HTMLField({ required: true, textSearch: true }),
}
}
}
export class CelestopolAspect extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
return {
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
reference: new fields.StringField({ required: true, nullable: false, initial: "" }),
scores: skillScoresSchema(),
description: new fields.HTMLField({ required: true, textSearch: true }),
technique: new fields.HTMLField({ required: true, textSearch: true }),
narratif: new fields.HTMLField({ required: true, textSearch: true }),
notes: new fields.HTMLField({ required: true, textSearch: true }),
}
}
}
export class CelestopolAttribute extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
return {
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
reference: new fields.StringField({ required: true, nullable: false, initial: "" }),
scores: skillScoresSchema(),
description: new fields.HTMLField({ required: true, textSearch: true }),
technique: new fields.HTMLField({ required: true, textSearch: true }),
narratif: new fields.HTMLField({ required: true, textSearch: true }),
notes: new fields.HTMLField({ required: true, textSearch: true }),
}
}
}
export class CelestopolEquipment extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
return {
subtype: new fields.StringField({ required: true, nullable: false, initial: "autre",
choices: Object.keys(SYSTEM.EQUIPMENT_TYPES) }),
reference: new fields.StringField({ required: true, nullable: false, initial: "" }),
quantity: new fields.NumberField({ ...reqInt, initial: 1, min: 0 }),
weight: new fields.NumberField({ required: true, nullable: false, initial: 0, min: 0 }),
damage: new fields.StringField({ required: true, nullable: false, initial: "" }),
range: new fields.StringField({ required: true, nullable: false, initial: "" }),
speed: new fields.StringField({ required: true, nullable: false, initial: "" }),
protection: new fields.StringField({ required: true, nullable: false, initial: "" }),
crew: new fields.StringField({ required: true, nullable: false, initial: "" }),
description:new fields.HTMLField({ required: true, textSearch: true }),
notes: new fields.HTMLField({ required: true, textSearch: true }),
}
}
}

99
module/models/npc.mjs Normal file
View File

@@ -0,0 +1,99 @@
import { SYSTEM } from "../config/system.mjs"
export default class CelestopolNPC extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields
const reqInt = { required: true, nullable: false, integer: true }
const schema = {}
schema.concept = new fields.StringField({ required: true, nullable: false, initial: "" })
schema.initiative = new fields.NumberField({ ...reqInt, initial: 0, min: 0 })
schema.anomaly = new fields.SchemaField({
type: new fields.StringField({ required: true, nullable: false, initial: "none",
choices: Object.keys(SYSTEM.ANOMALY_TYPES) }),
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
})
const skillField = (label) => new fields.SchemaField({
label: new fields.StringField({ required: true, initial: label }),
value: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
})
const statField = (statId) => {
const skills = SYSTEM.SKILLS[statId]
const skillSchema = {}
for (const [key, skill] of Object.entries(skills)) {
skillSchema[key] = skillField(skill.label)
}
return new fields.SchemaField({
label: new fields.StringField({ required: true, initial: SYSTEM.STATS[statId].label }),
res: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
actuel: new fields.NumberField({ ...reqInt, initial: 0, min: 0 }), // res + wound malus
...skillSchema,
})
}
schema.stats = new fields.SchemaField({
ame: statField("ame"),
corps: statField("corps"),
coeur: statField("coeur"),
esprit: statField("esprit"),
})
const woundField = (idx) => new fields.SchemaField({
checked: new fields.BooleanField({ required: true, initial: false }),
malus: new fields.NumberField({ ...reqInt, initial: SYSTEM.WOUND_LEVELS[idx]?.malus ?? 0 }),
})
schema.blessures = new fields.SchemaField({
lvl: new fields.NumberField({ ...reqInt, initial: 0, min: 0, max: 8 }),
b1: woundField(1), b2: woundField(2), b3: woundField(3), b4: woundField(4),
b5: woundField(5), b6: woundField(6), b7: woundField(7), b8: woundField(8),
})
schema.prefs = new fields.SchemaField({
moonPhase: new fields.StringField({ required: true, nullable: false, initial: "none" }),
difficulty: new fields.StringField({ required: true, nullable: false, initial: "normal" }),
})
schema.description = new fields.HTMLField({ required: true, textSearch: true })
schema.notes = new fields.HTMLField({ required: true, textSearch: true })
return schema
}
static LOCALIZATION_PREFIXES = ["CELESTOPOL.NPC"]
prepareDerivedData() {
super.prepareDerivedData()
const malus = this.getWoundMalus()
this.initiative = Math.max(0, this.stats.corps.res + malus)
for (const stat of Object.values(this.stats)) {
stat.actuel = Math.max(0, stat.res + malus)
}
}
getWoundMalus() {
const lvl = Math.max(0, Math.min(8, this.blessures.lvl))
return SYSTEM.WOUND_LEVELS[lvl]?.malus ?? 0
}
async roll(statId, skillId) {
const { CelestopolRoll } = await import("../documents/roll.mjs")
const skill = this.stats[statId][skillId]
if (!skill) return null
return CelestopolRoll.prompt({
actorId: this.parent.id,
actorName: this.parent.name,
actorImage: this.parent.img,
statId,
skillId,
skillLabel: skill.label,
skillValue: skill.value,
woundMalus: this.getWoundMalus(),
moonPhase: this.prefs.moonPhase,
difficulty: this.prefs.difficulty,
})
}
}