Initial release for FoundryVTT

This commit is contained in:
2026-04-13 15:53:13 +02:00
parent f61cbf0b78
commit 1ff1425777
193 changed files with 11270 additions and 0 deletions

View File

@@ -0,0 +1,223 @@
import { DonjonEtCieRolls } from "../donjon-et-cie-rolls.mjs";
import { DonjonEtCieUtility } from "../donjon-et-cie-utility.mjs";
export class DonjonEtCieRollDialog {
static async createInitiative(actor) {
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-donjon-et-cie/templates/dialogs/initiative-roll.hbs",
{
actorName: actor.name,
dex: actor.system.caracteristiques?.dexterite?.value ?? 0,
initiativeBonus: actor.system.combat?.initiativeBonus ?? 0
}
);
return foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.localize("DNC.Roll.Initiative"), icon: "fa-solid fa-bolt" },
classes: ["dnc-roll-dialog"],
content,
modal: false,
buttons: [
{
action: "roll",
label: "Lancer",
icon: "fa-solid fa-bolt",
default: true,
callback: async (event, button) => {
const form = button.form.elements;
return DonjonEtCieRolls.rollInitiative(actor, {
mode: form.mode?.value ?? "normal"
});
}
}
],
rejectClose: false
});
}
static async createCharacteristic(actor, characteristicKey) {
const characteristic = actor.system.caracteristiques?.[characteristicKey];
if (!characteristic) return;
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-donjon-et-cie/templates/dialogs/characteristic-roll.hbs",
{
actorName: actor.name,
characteristic,
characteristicKey,
favorOptions: DonjonEtCieUtility.getAvailableFavorOptions(actor),
hasFavorOptions: DonjonEtCieUtility.getAvailableFavorOptions(actor).length > 0
}
);
return foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.localize("DNC.Roll.Characteristic"), icon: "fa-solid fa-dice-d20" },
classes: ["dnc-roll-dialog"],
content,
modal: false,
buttons: [
{
action: "roll",
label: "Lancer",
icon: "fa-solid fa-dice-d20",
default: true,
callback: async (event, button) => {
const form = button.form.elements;
return DonjonEtCieRolls.rollCharacteristic(actor, characteristicKey, {
mode: form.mode?.value ?? "normal",
favorKey: form.favorDepartment?.value ?? ""
});
}
}
],
rejectClose: false
});
}
static async createWeapon(actor, item) {
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-donjon-et-cie/templates/dialogs/weapon-roll.hbs",
{
actorName: actor.name,
item,
characteristicLabel: DonjonEtCieUtility.getWeaponCharacteristicLabel(item.system.categorie),
characteristicValue: actor.system.caracteristiques?.[DonjonEtCieUtility.getWeaponCharacteristicKey(item.system.categorie)]?.value ?? 0,
favorOptions: DonjonEtCieUtility.getAvailableFavorOptions(actor),
hasFavorOptions: DonjonEtCieUtility.getAvailableFavorOptions(actor).length > 0
}
);
return foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.localize("DNC.Roll.Attack"), icon: "fa-solid fa-sword" },
classes: ["dnc-roll-dialog"],
content,
modal: false,
buttons: [
{
action: "roll",
label: "Attaquer",
icon: "fa-solid fa-sword",
default: true,
callback: async (event, button) => {
const form = button.form.elements;
return DonjonEtCieRolls.rollWeapon(actor, item, {
mode: form.mode?.value ?? "normal",
favorKey: form.favorDepartment?.value ?? ""
});
}
}
],
rejectClose: false
});
}
static async createSpell(actor, item) {
const characteristicKey = item.system.caracteristique || "intelligence";
const characteristic = actor.system.caracteristiques?.[characteristicKey];
const magicResources = DonjonEtCieUtility.getMagicResourceContext(actor);
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-donjon-et-cie/templates/dialogs/spell-roll.hbs",
{
actorName: actor.name,
item,
characteristic,
rank: magicResources.rank,
currentPv: actor.system.sante?.pv?.value ?? 0,
focusLabel: magicResources.focusLabel,
focusDisplay: magicResources.focusDisplay,
focusIsActive: magicResources.focusIsActive,
chaosLabel: magicResources.chaosLabel,
autoDisadvantage: Number(item.system.coutPv ?? 0) > magicResources.rank,
favorOptions: DonjonEtCieUtility.getAvailableFavorOptions(actor),
hasFavorOptions: DonjonEtCieUtility.getAvailableFavorOptions(actor).length > 0
}
);
return foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.localize("DNC.Roll.Spell"), icon: "fa-solid fa-wand-magic-sparkles" },
classes: ["dnc-roll-dialog"],
content,
modal: false,
buttons: [
{
action: "roll",
label: "Lancer",
icon: "fa-solid fa-wand-magic-sparkles",
default: true,
callback: async (event, button) => {
const form = button.form.elements;
return DonjonEtCieRolls.rollSpell(actor, item, {
mode: form.mode?.value ?? "normal",
favorKey: form.favorDepartment?.value ?? ""
});
}
}
],
rejectClose: false
});
}
static async createUsage(item) {
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-donjon-et-cie/templates/dialogs/usage-roll.hbs",
{
item
}
);
return foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.localize("DNC.Roll.Usage"), icon: "fa-solid fa-hourglass-half" },
classes: ["dnc-roll-dialog"],
content,
modal: false,
buttons: [
{
action: "roll",
label: "Utiliser",
icon: "fa-solid fa-hourglass-half",
default: true,
callback: async (event, button) => {
const form = button.form.elements;
return DonjonEtCieRolls.rollUsage(item, {
mode: form.mode?.value ?? "normal"
});
}
}
],
rejectClose: false
});
}
static async createDamage(actor, item) {
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-donjon-et-cie/templates/dialogs/damage-roll.hbs",
{
actorName: actor?.name ?? item.actor?.name ?? "",
item,
actorBonus: actor?.system?.combat?.degatsBonus ?? 0
}
);
return foundry.applications.api.DialogV2.wait({
window: { title: game.i18n.localize("DNC.Roll.Damage"), icon: "fa-solid fa-burst" },
classes: ["dnc-roll-dialog"],
content,
modal: false,
buttons: [
{
action: "roll",
label: "Lancer",
icon: "fa-solid fa-burst",
default: true,
callback: async (event, button) => {
const form = button.form.elements;
return DonjonEtCieRolls.rollDamage(actor, item, {
mode: form.mode?.value ?? "normal"
});
}
}
],
rejectClose: false
});
}
}

View File

@@ -0,0 +1,3 @@
export { default as DonjonEtCieItemSheet } from "./base-item-sheet.mjs";
export { default as DonjonEtCieEmployeSheet } from "./donjon-et-cie-employe-sheet.mjs";
export { default as DonjonEtCiePNJSheet } from "./donjon-et-cie-pnj-sheet.mjs";

View File

@@ -0,0 +1,225 @@
const { HandlebarsApplicationMixin } = foundry.applications.api;
export default class DonjonEtCieActorSheet extends HandlebarsApplicationMixin(foundry.applications.sheets.ActorSheetV2) {
static DEFAULT_OPTIONS = {
classes: ["fvtt-donjon-et-cie", "sheet", "actor"],
position: { width: 920, height: 820 },
form: {
submitOnChange: true,
closeOnSubmit: false
},
window: {
resizable: true
},
dragDrop: [{ dragSelector: ".item-list .item", dropSelector: ".item-dropzone" }],
actions: {
editImage: DonjonEtCieActorSheet.#onEditImage,
setTab: DonjonEtCieActorSheet.#onSetTab,
createItem: DonjonEtCieActorSheet.#onCreateItem,
editItem: DonjonEtCieActorSheet.#onEditItem,
deleteItem: DonjonEtCieActorSheet.#onDeleteItem,
rollHitDice: DonjonEtCieActorSheet.#onRollHitDice,
rollInitiative: DonjonEtCieActorSheet.#onRollInitiative,
rollCharacteristic: DonjonEtCieActorSheet.#onRollCharacteristic,
rollWeapon: DonjonEtCieActorSheet.#onRollWeapon,
rollDamage: DonjonEtCieActorSheet.#onRollDamage,
rollSpell: DonjonEtCieActorSheet.#onRollSpell,
rollUsage: DonjonEtCieActorSheet.#onRollUsage,
useFavorService: DonjonEtCieActorSheet.#onUseFavorService,
postItem: DonjonEtCieActorSheet.#onPostItem,
adjustCounter: DonjonEtCieActorSheet.#onAdjustCounter
}
};
async _prepareContext() {
const actor = this.document;
return {
actor,
system: actor.system,
source: actor.toObject(),
config: game.system.donjonEtCie.config,
characteristics: actor.getCharacteristicEntries(),
sections: actor.getSectionData(),
fields: actor.schema.fields,
systemFields: actor.system.schema.fields,
activeTab: this._activeTab ?? "combat"
};
}
_onRender(context, options) {
super._onRender(context, options);
this.#fixWindowShell();
this._applyActiveTab();
}
#fixWindowShell() {
const app = this.element?.matches?.(".application") ? this.element : this.element?.closest(".application");
const content = app?.querySelector(":scope > .window-content") ?? app?.querySelector(".window-content");
const header = app?.querySelector(".window-header");
if (app) {
app.style.display = "flex";
app.style.flexDirection = "column";
app.style.paddingTop = "0";
app.style.overflow = "hidden";
}
if (header) {
header.style.width = "100%";
header.style.flex = "0 0 auto";
header.style.position = "relative";
header.style.zIndex = "3";
}
if (content) {
content.style.width = "100%";
content.style.flex = "1 1 auto";
content.style.minHeight = "0";
content.style.overflowY = "auto";
content.style.overflowX = "hidden";
}
}
_canDragStart() {
return this.isEditable;
}
_canDragDrop() {
return this.isEditable;
}
_onDragStart(event) {
const itemElement = event.currentTarget.closest(".item");
if (!itemElement) return;
const itemId = itemElement.dataset.itemId;
const item = this.document.items.get(itemId);
if (!item) return;
event.dataTransfer.setData("text/plain", JSON.stringify({ type: "Item", uuid: item.uuid }));
}
_onDragOver(event) {
const dropTarget = event.target.closest(".item-section");
this.#setDropTarget(dropTarget);
}
async _onDrop(event) {
this.#setDropTarget(null);
return super._onDrop(event);
}
#setDropTarget(target) {
this.element.querySelectorAll(".item-section.is-dragover").forEach((section) => section.classList.remove("is-dragover"));
if (target instanceof HTMLElement) {
target.classList.add("is-dragover");
}
}
_applyActiveTab() {
const activeTab = this._activeTab ?? "combat";
this.element.querySelectorAll("[data-tab-button]").forEach((button) => {
const isActive = button.dataset.tab === activeTab;
button.classList.toggle("active", isActive);
button.setAttribute("aria-pressed", isActive ? "true" : "false");
});
this.element.querySelectorAll("[data-tab-panel]").forEach((panel) => {
const isActive = panel.dataset.tabPanel === activeTab;
panel.classList.toggle("active", isActive);
panel.toggleAttribute("hidden", !isActive);
});
}
static async #onEditImage(event) {
event.preventDefault();
const picker = new FilePicker({
type: "image",
current: this.document.img,
callback: (path) => this.document.update({ img: path })
});
return picker.browse();
}
static async #onCreateItem(event, target) {
event.preventDefault();
const type = target.dataset.type;
if (!type) return;
return this.document.createEmbeddedDocuments("Item", [{ name: `Nouveau ${type}`, type }], { renderSheet: true });
}
static async #onSetTab(event, target) {
event.preventDefault();
const tab = target.dataset.tab;
if (!tab) return;
this._activeTab = tab;
this._applyActiveTab();
}
static async #onEditItem(event, target) {
event.preventDefault();
const item = this.document.items.get(target.closest("[data-item-id]")?.dataset.itemId);
return item?.sheet.render(true);
}
static async #onDeleteItem(event, target) {
event.preventDefault();
const itemId = target.closest("[data-item-id]")?.dataset.itemId;
if (!itemId) return;
return this.document.deleteEmbeddedDocuments("Item", [itemId]);
}
static async #onRollCharacteristic(event, target) {
event.preventDefault();
return this.document.rollCharacteristic(target.dataset.characteristic);
}
static async #onRollInitiative(event) {
event.preventDefault();
return this.document.rollInitiative();
}
static async #onRollHitDice(event) {
event.preventDefault();
return this.document.rollHitDice();
}
static async #onRollWeapon(event, target) {
event.preventDefault();
return this.document.rollWeapon(target.closest("[data-item-id]")?.dataset.itemId);
}
static async #onRollDamage(event, target) {
event.preventDefault();
return this.document.rollDamage(target.closest("[data-item-id]")?.dataset.itemId);
}
static async #onRollSpell(event, target) {
event.preventDefault();
return this.document.rollSpell(target.closest("[data-item-id]")?.dataset.itemId);
}
static async #onRollUsage(event, target) {
event.preventDefault();
return this.document.rollUsage(target.closest("[data-item-id]")?.dataset.itemId);
}
static async #onUseFavorService(event, target) {
event.preventDefault();
return this.document.useFavorService(target.dataset.department);
}
static async #onPostItem(event, target) {
event.preventDefault();
const item = this.document.items.get(target.closest("[data-item-id]")?.dataset.itemId);
return item?.postToChat();
}
static async #onAdjustCounter(event, target) {
event.preventDefault();
const path = target.dataset.path;
const delta = Number(target.dataset.delta ?? 0);
if (!path || Number.isNaN(delta)) return;
return this.document.adjustNumericField(path, delta);
}
}

View File

@@ -0,0 +1,110 @@
import { DonjonEtCieUtility } from "../../donjon-et-cie-utility.mjs";
const { HandlebarsApplicationMixin } = foundry.applications.api;
export default class DonjonEtCieItemSheet extends HandlebarsApplicationMixin(foundry.applications.sheets.ItemSheetV2) {
static DEFAULT_OPTIONS = {
classes: ["fvtt-donjon-et-cie", "sheet", "item"],
position: { width: 640, height: 700 },
form: {
submitOnChange: true,
closeOnSubmit: false
},
window: {
resizable: true
},
actions: {
editImage: DonjonEtCieItemSheet.#onEditImage,
postItem: DonjonEtCieItemSheet.#onPostItem,
rollItem: DonjonEtCieItemSheet.#onRollItem,
rollDamageItem: DonjonEtCieItemSheet.#onRollDamageItem
}
};
static PARTS = {
main: { template: "systems/fvtt-donjon-et-cie/templates/items/item-sheet.hbs" }
};
async _prepareContext() {
const item = this.document;
return {
item,
system: item.system,
source: item.toObject(),
config: game.system.donjonEtCie.config,
fields: item.schema.fields,
systemFields: item.system.schema.fields,
isWeapon: item.type === "arme",
isArmor: item.type === "armure",
isConsumable: item.type === "consommable",
isSpell: item.type === "sortilege",
canRollDamage: Boolean(item.system.degats),
isEquipment: item.type === "equipement",
isCapacity: item.type === "capacite",
isLanguage: item.type === "langue",
isTrait: item.type === "trait",
armorProtectionDisplay: Number(item.system.resultatProtection ?? 0) > 0 ? item.system.resultatProtection : "—",
weaponCharacteristicLabel: item.type === "arme" ? DonjonEtCieUtility.getWeaponCharacteristicLabel(item.system.categorie) : null,
enrichedDescription: await foundry.applications.ux.TextEditor.implementation.enrichHTML(item.system.description ?? "", { async: true }),
enrichedNotes: await foundry.applications.ux.TextEditor.implementation.enrichHTML(item.system.notes ?? "", { async: true })
};
}
_onRender(context, options) {
super._onRender(context, options);
this.#fixWindowShell();
}
#fixWindowShell() {
const content = this.element?.closest(".window-content") ?? this.element?.parentElement;
const app = content?.closest(".application") ?? this.element?.closest(".application");
const header = app?.querySelector(".window-header");
if (app) {
app.style.display = "flex";
app.style.flexDirection = "column";
app.style.paddingTop = "0";
app.style.overflow = "hidden";
}
if (header) {
header.style.width = "100%";
header.style.flex = "0 0 auto";
header.style.position = "relative";
header.style.zIndex = "3";
}
if (content) {
content.style.width = "100%";
content.style.flex = "1 1 auto";
content.style.minHeight = "0";
content.style.overflowY = "auto";
content.style.overflowX = "hidden";
}
}
static async #onEditImage(event) {
event.preventDefault();
const picker = new FilePicker({
type: "image",
current: this.document.img,
callback: (path) => this.document.update({ img: path })
});
return picker.browse();
}
static async #onPostItem(event) {
event.preventDefault();
return this.document.postToChat();
}
static async #onRollItem(event) {
event.preventDefault();
return this.document.roll();
}
static async #onRollDamageItem(event) {
event.preventDefault();
return this.document.rollDamage();
}
}

View File

@@ -0,0 +1,37 @@
import DonjonEtCieActorSheet from "./base-actor-sheet.mjs";
import { DonjonEtCieUtility } from "../../donjon-et-cie-utility.mjs";
export default class DonjonEtCieEmployeSheet extends DonjonEtCieActorSheet {
static DEFAULT_OPTIONS = {
...super.DEFAULT_OPTIONS,
classes: [...super.DEFAULT_OPTIONS.classes, "employe"],
position: { width: 980, height: 860 }
};
static PARTS = {
main: { template: "systems/fvtt-donjon-et-cie/templates/actors/employe-sheet.hbs" }
};
async _prepareContext() {
const context = await super._prepareContext();
const indexedSections = Object.fromEntries(context.sections.map((section) => [section.key, section]));
const getSection = (key) => indexedSections[key] ?? {
key,
label: context.config.actorSections[key]?.label ?? key,
createType: context.config.actorSections[key]?.createType ?? key,
items: []
};
return {
...context,
magicResources: DonjonEtCieUtility.getMagicResourceContext(this.document),
favorEntries: this.document.getFavorEntries(),
chaosTable: DonjonEtCieUtility.getChaosTableEntries(),
traitsSection: getSection("traits"),
combatSections: ["armes", "armures", "consommables", "equipements"].map(getSection),
spellSection: getSection("sortileges"),
capacitySection: getSection("capacites"),
profileSections: ["langues"].map(getSection)
};
}
}

View File

@@ -0,0 +1,57 @@
import DonjonEtCieActorSheet from "./base-actor-sheet.mjs";
import { DonjonEtCieUtility } from "../../donjon-et-cie-utility.mjs";
export default class DonjonEtCiePNJSheet extends DonjonEtCieActorSheet {
static DEFAULT_OPTIONS = {
...super.DEFAULT_OPTIONS,
classes: [...super.DEFAULT_OPTIONS.classes, "pnj"],
position: { width: 840, height: 760 },
actions: {
...super.DEFAULT_OPTIONS.actions,
rollPnjArmor: DonjonEtCiePNJSheet.#onRollPnjArmor,
rollPnjCourage: DonjonEtCiePNJSheet.#onRollPnjCourage,
rollPnjAttackDamage: DonjonEtCiePNJSheet.#onRollPnjAttackDamage
}
};
static PARTS = {
main: { template: "systems/fvtt-donjon-et-cie/templates/actors/pnj-sheet.hbs" }
};
async _prepareContext() {
const context = await super._prepareContext();
const system = this.document.system;
const indexedSections = Object.fromEntries(context.sections.map((section) => [section.key, section]));
const getSection = (key) => indexedSections[key] ?? {
key,
label: context.config.actorSections[key]?.label ?? key,
createType: context.config.actorSections[key]?.createType ?? key,
items: []
};
return {
...context,
capacitySection: getSection("capacites"),
spellSection: getSection("sortileges"),
armorDisplay: Number(system.defense?.armure?.delta ?? 0) ? `Δ${system.defense.armure.delta}` : "—",
storedArmor: Number(system.defense?.armure?.resultatProtection ?? 0) > 0 ? system.defense.armure.resultatProtection : "—",
courageDisplay: Number(system.defense?.courage?.delta ?? 0) ? `Δ${system.defense.courage.delta}` : "—",
hasAttackDamage: Boolean(system.attaque?.degats)
};
}
static async #onRollPnjArmor(event) {
event.preventDefault();
return this.document.rollPnjArmor();
}
static async #onRollPnjCourage(event) {
event.preventDefault();
return this.document.rollPnjCourage();
}
static async #onRollPnjAttackDamage(event) {
event.preventDefault();
return this.document.rollPnjAttackDamage();
}
}

View File

@@ -0,0 +1,203 @@
import { DonjonEtCieUtility } from "./donjon-et-cie-utility.mjs";
import { DonjonEtCieRollDialog } from "./applications/donjon-et-cie-roll-dialog.mjs";
export class DonjonEtCieActor extends Actor {
prepareDerivedData() {
super.prepareDerivedData();
const pv = this.system.sante?.pv;
if (pv && pv.value > pv.max) {
pv.max = pv.value;
}
}
getCharacteristicEntries() {
return DonjonEtCieUtility.getCharacteristicEntries(this.system);
}
getSectionData() {
return DonjonEtCieUtility.buildActorSections(this);
}
getFavorEntries() {
return DonjonEtCieUtility.getFavorEntries(this.system);
}
#getStoredArmorContext() {
if (this.type === "pnj") {
const stored = Number(this.system.defense?.armure?.resultatProtection ?? 0);
return {
label: "ARM",
hasArmor: true,
before: stored,
update: async (value) => this.update({ "system.defense.armure.resultatProtection": Math.max(0, Number(value ?? 0)) })
};
}
const armors = [...this.items.filter((item) => item.type === "armure")].sort((a, b) => {
const equippedScore = Number(Boolean(b.system.equipee)) - Number(Boolean(a.system.equipee));
if (equippedScore) return equippedScore;
const protectionScore = Number(b.system.resultatProtection ?? 0) - Number(a.system.resultatProtection ?? 0);
if (protectionScore) return protectionScore;
return a.name.localeCompare(b.name, "fr", { sensitivity: "base" });
});
const armor = armors.find((item) => item.system.equipee || Number(item.system.resultatProtection ?? 0) > 0) ?? null;
if (!armor) {
return {
label: "Armure",
hasArmor: false,
before: 0,
update: null
};
}
return {
label: armor.name,
hasArmor: true,
before: Number(armor.system.resultatProtection ?? 0),
update: async (value) => armor.update({ "system.resultatProtection": Math.max(0, Number(value ?? 0)) })
};
}
async adjustNumericField(path, delta) {
const current = Number(foundry.utils.getProperty(this, path) ?? 0);
let next = current + Number(delta);
if (path === "system.sante.pv.value") {
const max = Number(this.system.sante?.pv?.max ?? next);
next = Math.max(0, Math.min(next, max));
} else {
next = Math.max(0, next);
}
return this.update({ [path]: next });
}
async applyIncomingDamage(damage, { useArmor = false } = {}) {
const incoming = Math.max(0, Number(damage ?? 0));
const pvBefore = Number(this.system.sante?.pv?.value ?? 0);
const pvMax = Number(this.system.sante?.pv?.max ?? pvBefore);
const armor = this.#getStoredArmorContext();
const armorBefore = useArmor ? Number(armor.before ?? 0) : 0;
const armorAbsorbed = Math.min(incoming, armorBefore);
const armorAfter = Math.max(armorBefore - armorAbsorbed, 0);
const hpDamage = Math.max(incoming - armorAbsorbed, 0);
const pvAfter = Math.max(pvBefore - hpDamage, 0);
if (useArmor && armor.hasArmor && armor.update && armorAfter !== armorBefore) {
await armor.update(armorAfter);
}
if (hpDamage !== 0) {
await this.update({ "system.sante.pv.value": pvAfter });
}
return {
incoming,
useArmor,
armorLabel: armor.label,
armorAvailable: armor.hasArmor,
armorBefore,
armorAbsorbed,
armorAfter,
hpDamage,
pvBefore,
pvAfter,
pvMax
};
}
async rollCharacteristic(key) {
return DonjonEtCieRollDialog.createCharacteristic(this, key);
}
async useFavorService(departmentKey) {
return game.system.donjonEtCie.rolls.useFavorService(this, departmentKey);
}
async rollInitiative() {
return DonjonEtCieRollDialog.createInitiative(this);
}
async rollHitDice() {
return game.system.donjonEtCie.rolls.rollHitDice(this);
}
async rollWeapon(itemId) {
const item = this.items.get(itemId);
if (item) return DonjonEtCieRollDialog.createWeapon(this, item);
}
async rollDamage(itemId) {
const item = this.items.get(itemId);
if (item) return DonjonEtCieRollDialog.createDamage(this, item);
}
async rollSpell(itemId) {
const item = this.items.get(itemId);
if (item) return DonjonEtCieRollDialog.createSpell(this, item);
}
async rollUsage(itemId) {
const item = this.items.get(itemId);
if (item) return DonjonEtCieRollDialog.createUsage(item);
}
#createPnjResourceProxy({ label, deltaPath, protectionPath = null }) {
const delta = Number(foundry.utils.getProperty(this, deltaPath) ?? 0);
const protection = protectionPath ? Number(foundry.utils.getProperty(this, protectionPath) ?? 0) : 0;
return {
actor: this,
type: protectionPath ? "armure" : "ressource",
name: `${this.name} · ${label}`,
system: {
delta,
resultatProtection: protection
},
update: async (data) => {
const updateData = {};
if (Object.hasOwn(data, "system.delta")) {
updateData[deltaPath] = data["system.delta"];
}
if (protectionPath && Object.hasOwn(data, "system.resultatProtection")) {
updateData[protectionPath] = data["system.resultatProtection"];
}
return Object.keys(updateData).length ? this.update(updateData) : this;
}
};
}
async rollPnjArmor() {
return DonjonEtCieRollDialog.createUsage(this.#createPnjResourceProxy({
label: "ARM",
deltaPath: "system.defense.armure.delta",
protectionPath: "system.defense.armure.resultatProtection"
}));
}
async rollPnjCourage() {
return DonjonEtCieRollDialog.createUsage(this.#createPnjResourceProxy({
label: "COU",
deltaPath: "system.defense.courage.delta"
}));
}
async rollPnjAttackDamage() {
const attackName = this.system.attaque?.nom || "Attaque";
const attackDamage = this.system.attaque?.degats || "";
if (!attackDamage) return null;
return DonjonEtCieRollDialog.createDamage(this, {
name: `${this.name} · ${attackName}`,
type: "attaque",
system: {
degats: attackDamage,
portee: this.system.attaque?.notes || ""
}
});
}
}

View File

@@ -0,0 +1,117 @@
export const DONJON_ET_CIE = {
id: "fvtt-donjon-et-cie",
characteristics: {
force: { label: "FORce", short: "FOR" },
dexterite: { label: "DEXterite", short: "DEX" },
constitution: { label: "CONstitution", short: "CON" },
intelligence: { label: "INTelligence", short: "INT" },
sagesse: { label: "SAGesse", short: "SAG" },
charisme: { label: "CHArisme", short: "CHA" }
},
characteristicOptions: {
force: "FORce",
dexterite: "DEXterite",
constitution: "CONstitution",
intelligence: "INTelligence",
sagesse: "SAGesse",
charisme: "CHArisme"
},
usageDieOptions: {
0: "Aucun",
4: "Δ4",
6: "Δ6",
8: "Δ8",
10: "Δ10",
12: "Δ12"
},
favorDepartments: {
entreesSorties: "Entrees et Sorties",
relationsMecenes: "Relations Mecenes",
relationsInterieures: "Relations Interieures",
conception: "Conception",
materiel: "Materiel",
arpentage: "Arpentage",
terminaison: "Terminaison",
recrutement: "Recrutement",
reception: "Reception",
conditionnement: "Conditionnement",
supervision: "Supervision",
exploration: "Exploration",
reclame: "Reclame",
entretien: "Entretien"
},
chaosTable: {
1: {
title: "Erreur",
effect: "L'effet du sort est inverse ou transforme de maniere dramatique."
},
2: {
title: "Mutation",
effect: "La magie fonctionne, mais transforme le personnage et laisse des sequelles : deformation, cicatrice, etc."
},
3: {
title: "Oubli",
effect: "Le sort fonctionne, mais le personnage l'oublie et ne s'en souviendra qu'apres une bonne nuit de sommeil."
},
4: {
title: "Drain",
effect: "Le personnage perd un nombre de points egal au cout du sort dans une caracteristique determinee au hasard. Ces points se recuperent au rythme d'un par jour."
},
5: {
title: "Feu d'artifice",
effect: "Du bruit, de la lumiere, aucun effet tangible, sinon que les vetements du magicien prennent certainement feu."
},
6: {
title: "Pic de pouvoir",
effect: "Aucune magie ne prend effet, mais le personnage regagne les points de vie depenses pour le sort."
},
7: {
title: "Sort amoindri",
effect: "La zone d'effet, le nombre de cibles, les dommages, tout est divise par deux."
},
8: {
title: "Absence de controle",
effect: "Votre magie a un effet secondaire negatif."
},
9: {
title: "Fuite de pouvoir",
effect: "Le sort fonctionne mais coute le double de son cout en PV (en tout). Si le personnage tombe a 0 PV, il perd connaissance."
},
10: {
title: "Effet retarde",
effect: "La magie prend effet normalement... mais dans d4 tours."
},
11: {
title: "Mal vise",
effect: "Le sort affecte une autre cible que celle que vous visez, au choix du MJ."
},
12: {
title: "BAM !",
effect: "Les effets, nombre de cibles ou taille du sort sont doubles."
}
},
weaponCategoryOptions: {
melee: "Corps a corps",
distance: "Distance"
},
actorSections: {
traits: { label: "Traits", createType: "trait" },
langues: { label: "Langues", createType: "langue" },
capacites: { label: "Capacites", createType: "capacite" },
sortileges: { label: "Sortileges", createType: "sortilege" },
armes: { label: "Armes", createType: "arme" },
armures: { label: "Armures", createType: "armure" },
equipements: { label: "Equipements", createType: "equipement" },
consommables: { label: "Consommables", createType: "consommable" }
},
sectionTypes: {
traits: ["trait"],
langues: ["langue"],
capacites: ["capacite"],
sortileges: ["sortilege"],
armes: ["arme"],
armures: ["armure"],
equipements: ["equipement"],
consommables: ["consommable"]
}
};

View File

@@ -0,0 +1,45 @@
import { DonjonEtCieRollDialog } from "./applications/donjon-et-cie-roll-dialog.mjs";
import { DonjonEtCieUtility } from "./donjon-et-cie-utility.mjs";
export class DonjonEtCieItem extends Item {
async _preCreate(data, options, user) {
await super._preCreate(data, options, user);
const currentImg = data.img ?? this.img;
if (currentImg && !currentImg.startsWith("icons/svg/")) return;
this.updateSource({ img: DonjonEtCieUtility.getDefaultItemIcon(this.type) });
}
get usageDie() {
return Number(this.system.delta ?? 0);
}
async roll() {
if (this.type === "arme") return DonjonEtCieRollDialog.createWeapon(this.actor, this);
if (this.type === "sortilege") return DonjonEtCieRollDialog.createSpell(this.actor, this);
if (this.usageDie) return DonjonEtCieRollDialog.createUsage(this);
return this.postToChat();
}
async rollDamage() {
if (!this.system.degats) return null;
return DonjonEtCieRollDialog.createDamage(this.actor, this);
}
async postToChat() {
const content = await foundry.applications.handlebars.renderTemplate(
"systems/fvtt-donjon-et-cie/templates/chat/item-card.hbs",
{
item: this,
usageLabel: DonjonEtCieUtility.formatUsageDie(this.usageDie)
}
);
return ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor: this.actor }),
user: game.user.id,
content
});
}
}

View File

@@ -0,0 +1,103 @@
import { DONJON_ET_CIE } from "./donjon-et-cie-config.mjs";
import { DonjonEtCieUtility } from "./donjon-et-cie-utility.mjs";
import { DonjonEtCieActor } from "./donjon-et-cie-actor.mjs";
import { DonjonEtCieItem } from "./donjon-et-cie-item.mjs";
import * as models from "./models/index.mjs";
import * as sheets from "./applications/sheets/_module.mjs";
import { DonjonEtCieRollDialog } from "./applications/donjon-et-cie-roll-dialog.mjs";
import { DonjonEtCieRolls } from "./donjon-et-cie-rolls.mjs";
function onChatActionClick(event) {
const button = event.target.closest("[data-action='rollChatDamage'], [data-action='rollSpellChaos'], [data-action='applyDamage']");
if (!(button instanceof HTMLElement)) return;
event.preventDefault();
void (async () => {
if (button.dataset.action === "rollSpellChaos") {
const actorUuid = button.dataset.actorUuid;
const itemUuid = button.dataset.itemUuid;
if (!actorUuid || !itemUuid) return;
const [actor, item] = await Promise.all([fromUuid(actorUuid), fromUuid(itemUuid)]);
return DonjonEtCieRolls.rollSpellChaos(actor, item);
}
if (button.dataset.action === "applyDamage") {
const card = button.closest(".dnc-chat-card-damage");
const select = card?.querySelector("[data-role='damage-target']");
const targetUuid = select instanceof HTMLSelectElement ? select.value : "";
if (!targetUuid) {
ui.notifications.warn(game.i18n.localize("DNC.Chat.SelectTarget"));
return null;
}
const target = await fromUuid(targetUuid);
if (!target) {
ui.notifications.warn(game.i18n.localize("DNC.Chat.TargetUnavailable"));
return null;
}
return DonjonEtCieRolls.applyDamage(target, {
damage: Number(button.dataset.damage ?? 0),
useArmor: button.dataset.useArmor === "true",
sourceLabel: button.dataset.sourceLabel ?? ""
});
}
const itemUuid = button.dataset.itemUuid;
if (!itemUuid) return;
const item = await fromUuid(itemUuid);
return item?.rollDamage?.();
})();
}
Hooks.once("init", async () => {
console.log("Initialisation du systeme Donjon & Cie");
await DonjonEtCieUtility.preloadHandlebarsTemplates();
CONFIG.Combat.initiative = {
formula: "1d20 + @system.caracteristiques.dexterite.value + @system.combat.initiativeBonus",
decimals: 0
};
CONFIG.Actor.documentClass = DonjonEtCieActor;
CONFIG.Actor.dataModels = {
employe: models.EmployeDataModel,
pnj: models.PnjDataModel
};
CONFIG.Item.documentClass = DonjonEtCieItem;
CONFIG.Item.dataModels = {
trait: models.TraitDataModel,
langue: models.LangueDataModel,
capacite: models.CapaciteDataModel,
sortilege: models.SortilegeDataModel,
arme: models.ArmeDataModel,
armure: models.ArmureDataModel,
equipement: models.EquipementDataModel,
consommable: models.ConsommableDataModel
};
game.system.donjonEtCie = {
config: DONJON_ET_CIE,
models,
sheets,
rolls: DonjonEtCieRolls,
dialogs: DonjonEtCieRollDialog,
utility: DonjonEtCieUtility
};
foundry.documents.collections.Actors.unregisterSheet("core", foundry.appv1.sheets.ActorSheet);
foundry.documents.collections.Actors.registerSheet("fvtt-donjon-et-cie", sheets.DonjonEtCieEmployeSheet, { types: ["employe"], makeDefault: true });
foundry.documents.collections.Actors.registerSheet("fvtt-donjon-et-cie", sheets.DonjonEtCiePNJSheet, { types: ["pnj"], makeDefault: true });
foundry.documents.collections.Items.unregisterSheet("core", foundry.appv1.sheets.ItemSheet);
for (const type of ["trait", "langue", "capacite", "sortilege", "arme", "armure", "equipement", "consommable"]) {
foundry.documents.collections.Items.registerSheet("fvtt-donjon-et-cie", sheets.DonjonEtCieItemSheet, { types: [type], makeDefault: true });
}
});
Hooks.once("ready", () => {
document.addEventListener("click", onChatActionClick);
});

View File

@@ -0,0 +1,546 @@
import { DonjonEtCieUtility } from "./donjon-et-cie-utility.mjs";
import { DONJON_ET_CIE } from "./donjon-et-cie-config.mjs";
export class DonjonEtCieRolls {
static async #createChatCard(actor, template, context) {
const content = await foundry.applications.handlebars.renderTemplate(template, context);
await ChatMessage.create({
speaker: ChatMessage.getSpeaker({ actor }),
user: game.user.id,
content
});
}
static #selectKeptValue(values, mode, favorable = "low") {
if (!values.length) return null;
if (mode === "normal") return values[0];
const selector = favorable === "low"
? (mode === "avantage" ? Math.min : Math.max)
: (mode === "avantage" ? Math.max : Math.min);
return selector(...values);
}
static #getModeLabel(mode) {
if (mode === "avantage") return "Avantage";
if (mode === "desavantage") return "Desavantage";
return null;
}
static #applyFavorMode(mode) {
if (mode === "desavantage") return "normal";
return "avantage";
}
static async #resolveFormulaRoll(formula, data = {}, { mode = "normal", favorable = "high" } = {}) {
const rollCount = mode === "normal" ? 1 : 2;
const rolls = await Promise.all(Array.from({ length: rollCount }, () => (new Roll(formula, data)).evaluate()));
const values = rolls.map((roll) => roll.total);
const kept = this.#selectKeptValue(values, mode, favorable);
const keptIndex = Math.max(0, values.findIndex((value) => value === kept));
const keptRoll = rolls[keptIndex] ?? rolls[0];
return { rolls, values, kept, keptIndex, keptRoll, mode, formula: keptRoll.formula };
}
static async #resolveCharacteristic(actor, characteristicKey, { mode = "normal" } = {}) {
const characteristic = actor.system.caracteristiques?.[characteristicKey];
if (!characteristic) return null;
const target = Number(characteristic.value ?? 0);
const rollCount = mode === "normal" ? 1 : 2;
const roll = await (new Roll(`${rollCount}d20`)).evaluate();
const values = roll.dice[0]?.results?.map((result) => result.result) ?? [];
const kept = this.#selectKeptValue(values, mode, "low");
const success = kept <= target;
return { characteristic, characteristicKey, target, values, kept, success, mode, isNaturalOne: kept === 1, isNaturalTwenty: kept === 20 };
}
static async #resolveFavorBoost(actor, favorKey, mode = "normal") {
if (!favorKey) return null;
const label = DonjonEtCieUtility.getFavorLabel(favorKey);
const path = `system.faveurs.${favorKey}.delta`;
const before = Number(foundry.utils.getProperty(actor, path) ?? 0);
if (!before) {
ui.notifications.warn(`Aucune faveur disponible pour ${label}.`);
return null;
}
const resolved = await this.#resolveFormulaRoll(`1d${before}`, {}, { favorable: "high" });
const result = resolved.kept;
const degraded = result <= 3;
const after = degraded ? DonjonEtCieUtility.degradeUsageDie(before) : before;
if (after !== before) {
await actor.update({ [path]: after });
}
return {
key: favorKey,
label,
before,
after,
result,
degraded,
stable: !degraded,
effectiveMode: this.#applyFavorMode(mode),
modeBefore: mode,
modeAfter: this.#applyFavorMode(mode),
note: degraded
? "Le coup de pouce reste anonyme : un collegue du departement a donne l'info utile."
: "Le coup de pouce tient bon : nommez le collegue, ses trois traits et la relation pour le trombinoscope."
};
}
static async useFavorService(actor, favorKey) {
if (!favorKey) return null;
const label = DonjonEtCieUtility.getFavorLabel(favorKey);
const path = `system.faveurs.${favorKey}.delta`;
const before = Number(foundry.utils.getProperty(actor, path) ?? 0);
if (!before) {
ui.notifications.warn(`Aucune faveur disponible pour ${label}.`);
return null;
}
const after = DonjonEtCieUtility.degradeUsageDie(before);
await actor.update({ [path]: after });
await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/favor-card.hbs", {
title: game.i18n.localize("DNC.Roll.Favor"),
subtitle: label,
kindLabel: "Service",
before: DonjonEtCieUtility.formatUsageDie(before),
after: DonjonEtCieUtility.formatUsageDie(after),
autoSpent: true,
note: "La faveur est brulee pour obtenir directement l'aide souhaitee, a la discretion du MJ."
});
return { key: favorKey, label, before, after };
}
static async #ensureFocus(actor) {
const focusDelta = Number(actor.system.magie?.focus?.delta ?? 0);
const focusResult = Number(actor.system.magie?.focus?.resultat ?? 0);
const focusSceneId = actor.system.magie?.focus?.sceneId ?? "";
const currentSceneId = DonjonEtCieUtility.getCurrentSceneId();
const sameScene = focusSceneId === currentSceneId;
const activeFocus = sameScene ? focusResult : 0;
if (!focusDelta) {
return { delta: 0, activeValue: 0, rolled: false, before: 0, after: 0, degraded: false };
}
if (sameScene) {
return { delta: focusDelta, activeValue: activeFocus, rolled: false, before: focusDelta, after: focusDelta, degraded: false };
}
const resolved = await this.#resolveFormulaRoll(`1d${focusDelta}`, {}, { favorable: "high" });
const result = resolved.kept;
const degraded = result <= 3;
const after = degraded ? DonjonEtCieUtility.degradeUsageDie(focusDelta) : focusDelta;
const updateData = {
"system.magie.focus.resultat": result,
"system.magie.focus.sceneId": currentSceneId
};
if (after !== focusDelta) {
updateData["system.magie.focus.delta"] = after;
}
await actor.update(updateData);
return {
delta: after,
activeValue: result,
rolled: true,
before: focusDelta,
after,
degraded,
values: resolved.values
};
}
static async rollCharacteristic(actor, characteristicKey, { mode = "normal", label = null, favorKey = "" } = {}) {
const favor = await this.#resolveFavorBoost(actor, favorKey, mode);
const effectiveMode = favor?.effectiveMode ?? mode;
const result = await this.#resolveCharacteristic(actor, characteristicKey, { mode: effectiveMode });
if (!result) return null;
await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/roll-card.hbs", {
title: label ?? "Jet de caracteristique",
subtitle: result.characteristic.label,
formula: result.values.length > 1 ? "2d20" : "1d20",
mode: effectiveMode,
modeLabel: this.#getModeLabel(effectiveMode),
target: result.target,
targetPillLabel: "Cible",
targetPillValue: result.target,
values: result.values,
kept: result.kept,
keptPillLabel: "Garde",
keptPillValue: result.kept,
success: result.success,
favorLabel: favor?.label ?? null,
favorNote: favor?.note ?? null,
details: [
{ label: "Caracteristique", value: result.characteristic.label },
{ label: "Valeur cible", value: result.target },
...(favor ? [
{ label: "Faveur", value: favor.label },
{ label: "Dé de faveur", value: favor.result },
{ label: "Avant", value: DonjonEtCieUtility.formatUsageDie(favor.before) },
{ label: "Apres", value: DonjonEtCieUtility.formatUsageDie(favor.after) }
] : [])
]
});
return { ...result, favor, mode: effectiveMode };
}
static async rollInitiative(actor, { mode = "normal" } = {}) {
const dex = Number(actor.system.caracteristiques?.dexterite?.value ?? 0);
const sheetBonus = Number(actor.system.combat?.initiativeBonus ?? 0);
const result = await this.#resolveFormulaRoll("1d20 + @dex + @sheetBonus", { dex, sheetBonus }, { mode, favorable: "high" });
const dieValues = result.rolls.map((roll) => roll.dice[0]?.results?.[0]?.result ?? roll.total);
const die = dieValues[result.keptIndex] ?? dieValues[0] ?? result.kept;
let syncedCombat = null;
const activeCombat = game.combats?.contents?.find((combat) => combat.active);
const combatant = activeCombat?.combatants?.find((entry) => entry.actorId === actor.id);
if (combatant) {
await activeCombat.setInitiative(combatant.id, result.kept);
const ordered = [...activeCombat.combatants].sort((a, b) => (b.initiative ?? -Infinity) - (a.initiative ?? -Infinity));
syncedCombat = {
name: activeCombat.name,
initiative: result.kept,
rank: ordered.findIndex((entry) => entry.id === combatant.id) + 1,
total: ordered.length
};
}
await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/initiative-card.hbs", {
title: game.i18n.localize("DNC.Roll.Initiative"),
actorName: actor.name,
total: result.kept,
formula: result.rolls.length > 1 ? `2 × ${result.formula}` : result.formula,
die,
dieValues,
dex,
bonus: sheetBonus,
mode: result.mode,
modeLabel: this.#getModeLabel(result.mode),
syncedCombat
});
return { total: result.kept, die, dieValues, dex, bonus: sheetBonus, mode: result.mode, syncedCombat };
}
static async rollHitDice(actor) {
const formula = String(actor.system.sante?.dv ?? "").trim();
if (!formula) return null;
let roll;
try {
roll = await (new Roll(formula)).evaluate();
} catch (error) {
ui.notifications.error(`Formule de DV invalide : ${formula}`);
throw error;
}
const dieValues = roll.dice.flatMap((die) => die.results?.map((result) => result.result) ?? []);
await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/hit-dice-card.hbs", {
title: game.i18n.localize("DNC.Roll.HitDice"),
actorName: actor.name,
formula: roll.formula,
total: roll.total,
dieValues
});
return { formula: roll.formula, total: roll.total, dieValues };
}
static async rollWeapon(actor, item, { mode = "normal", favorKey = "" } = {}) {
const characteristicKey = DonjonEtCieUtility.getWeaponCharacteristicKey(item.system.categorie);
const favor = await this.#resolveFavorBoost(actor, favorKey, mode);
const effectiveMode = favor?.effectiveMode ?? mode;
const result = await this.#resolveCharacteristic(actor, characteristicKey, { mode: effectiveMode });
if (!result) return null;
const characteristicLabel = DONJON_ET_CIE.characteristics[characteristicKey]?.label ?? characteristicKey;
const characteristicShort = DONJON_ET_CIE.characteristics[characteristicKey]?.short ?? characteristicKey;
await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/roll-card.hbs", {
title: `${game.i18n.localize("DNC.Roll.Attack")} : ${item.name}`,
subtitle: DONJON_ET_CIE.weaponCategoryOptions[item.system.categorie] ?? item.system.categorie,
formula: result.values.length > 1 ? "2d20" : "1d20",
mode: effectiveMode,
modeLabel: this.#getModeLabel(effectiveMode),
target: result.target,
targetPillLabel: characteristicShort,
targetPillValue: result.target,
values: result.values,
kept: result.kept,
keptPillLabel: "Jet",
keptPillValue: result.kept,
success: result.success,
favorLabel: favor?.label ?? null,
favorNote: favor?.note ?? null,
showDamageButton: result.success && Boolean(item.system.degats),
itemUuid: item.uuid,
details: [
{ label: "Arme", value: item.name },
{ label: "Caracteristique", value: characteristicLabel },
{ label: `Valeur de ${characteristicLabel}`, value: result.target },
{ label: "Degats", value: item.system.degats || "—" },
{ label: "Portee", value: item.system.portee || "—" },
...(favor ? [
{ label: "Faveur", value: favor.label },
{ label: "Dé de faveur", value: favor.result },
{ label: "Avant", value: DonjonEtCieUtility.formatUsageDie(favor.before) },
{ label: "Apres", value: DonjonEtCieUtility.formatUsageDie(favor.after) }
] : [])
]
});
return { ...result, favor, mode: effectiveMode };
}
static async rollDamage(actor, item, { mode = "normal" } = {}) {
if (!item.system.degats) return null;
const actorBonus = Number(actor?.system?.combat?.degatsBonus ?? 0);
const totalBonus = actorBonus;
const formula = totalBonus ? `${item.system.degats} + ${totalBonus}` : item.system.degats;
const result = await this.#resolveFormulaRoll(formula, {}, { mode, favorable: "high" });
const targets = DonjonEtCieUtility.getSceneDamageTargets();
const rollDieLabels = result.rolls.map((roll) => {
const dieValues = roll.dice.flatMap((die) => die.results?.map((dieResult) => dieResult.result) ?? []);
return dieValues.length ? dieValues.join(" + ") : String(roll.total ?? "—");
});
const keptDieLabel = rollDieLabels[result.keptIndex] ?? rollDieLabels[0] ?? String(result.kept);
await this.#createChatCard(actor ?? item.actor, "systems/fvtt-donjon-et-cie/templates/chat/damage-card.hbs", {
title: `${game.i18n.localize("DNC.Roll.Damage")} : ${item.name}`,
subtitle: item.system.portee || item.type,
formula: result.rolls.length > 1 ? `2 × ${result.formula}` : result.formula,
mode: result.mode,
modeLabel: this.#getModeLabel(result.mode),
rollDieLabels,
keptDieLabel,
values: result.values,
total: result.kept,
bonus: totalBonus,
baseDamage: item.system.degats,
sourceLabel: item.name,
targets,
hasTargets: targets.length > 0
});
return { total: result.kept, formula: result.formula, bonus: totalBonus, values: result.values, mode: result.mode };
}
static async applyDamage(target, { damage = 0, useArmor = false, sourceLabel = "" } = {}) {
const actor = target?.actor ?? target;
if (!actor || actor.documentName !== "Actor") {
ui.notifications.warn(game.i18n.localize("DNC.Chat.InvalidDamageTarget"));
return null;
}
const targetName = target?.name ?? actor.name;
const applied = await actor.applyIncomingDamage(damage, { useArmor });
await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/damage-application-card.hbs", {
title: game.i18n.localize("DNC.Chat.DamageApplied"),
subtitle: targetName,
sourceLabel,
total: applied.hpDamage,
incoming: applied.incoming,
useArmor: applied.useArmor,
armorLabel: applied.armorLabel,
armorAvailable: applied.armorAvailable,
armorBefore: applied.armorBefore,
armorAbsorbed: applied.armorAbsorbed,
armorAfter: applied.armorAfter,
pvBefore: applied.pvBefore,
pvAfter: applied.pvAfter,
pvMax: applied.pvMax
});
return { actor, targetName, ...applied };
}
static async rollSpell(actor, item, { mode = "normal", favorKey = "" } = {}) {
const characteristicKey = item.system.caracteristique || "intelligence";
const focus = await this.#ensureFocus(actor);
const rank = Number(actor.system.anciennete?.rang ?? actor.system.sante?.dv ?? 0);
const cost = Number(item.system.coutPv ?? 0);
const autoDisadvantage = cost > rank;
const baseMode = autoDisadvantage ? "desavantage" : mode;
const favor = await this.#resolveFavorBoost(actor, favorKey, baseMode);
const effectiveMode = favor?.effectiveMode ?? baseMode;
const result = await this.#resolveCharacteristic(actor, characteristicKey, { mode: effectiveMode });
if (!result) return null;
const currentPv = Number(actor.system.sante?.pv?.value ?? 0);
const availableMagicHp = currentPv + focus.activeValue;
if (cost > availableMagicHp) {
ui.notifications.warn("Le lanceur ne dispose pas d'assez de PV et de focus pour payer ce sort.");
return null;
}
const characteristicShort = DONJON_ET_CIE.characteristics[characteristicKey]?.short ?? characteristicKey;
const success = result.isNaturalTwenty ? false : result.success;
const focusSpent = result.isNaturalOne ? 0 : Math.min(cost, focus.activeValue);
const focusRemaining = Math.max(focus.activeValue - focusSpent, 0);
const spentPv = result.isNaturalOne ? 0 : Math.max(cost - focusSpent, 0);
const remainingPv = Math.max(currentPv - spentPv, 0);
const updateData = {};
if (spentPv !== 0) {
updateData["system.sante.pv.value"] = remainingPv;
}
if (focusSpent !== 0) {
updateData["system.magie.focus.resultat"] = focusRemaining;
}
if (Object.keys(updateData).length) {
await actor.update(updateData);
}
const canInvokeChaos = !success && !result.isNaturalTwenty && Number(actor.system.magie?.chaos?.delta ?? 12) >= 4;
const specialNote = result.isNaturalTwenty
? "20 naturel : la magie tourne a la catastrophe, au choix du MJ."
: (result.isNaturalOne ? "1 naturel : effet benefique possible ; par defaut, aucun PV n'est depense." : null);
await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/spell-card.hbs", {
title: `${game.i18n.localize("DNC.Roll.Spell")} : ${item.name}`,
subtitle: item.system.portee || "Sortilege",
formula: result.values.length > 1 ? "2d20" : "1d20",
mode: effectiveMode,
modeLabel: this.#getModeLabel(effectiveMode),
autoDisadvantage,
autoDisadvantageCanceled: autoDisadvantage && Boolean(favor),
favorLabel: favor?.label ?? null,
favorNote: favor?.note ?? null,
targetPillLabel: characteristicShort,
targetPillValue: result.target,
values: result.values,
kept: result.kept,
keptPillLabel: "Jet",
keptPillValue: result.kept,
success,
specialNote,
showDamageButton: success && Boolean(item.system.degats),
showChaosButton: canInvokeChaos,
itemUuid: item.uuid,
actorUuid: actor.uuid,
details: [
{ label: "Sortilege", value: item.name },
{ label: "Caracteristique", value: result.characteristic.label },
{ label: "Valeur de la caracteristique", value: result.target },
{ label: "Cout en PV", value: cost },
{ label: "Focus", value: focus.activeValue > 0 ? `${focus.activeValue} (${DonjonEtCieUtility.formatUsageDie(focus.before)})` : "—" },
{ label: "Focus depense", value: focusSpent },
{ label: "Focus restant", value: focusRemaining },
{ label: "PV depenses", value: spentPv },
{ label: "PV restants", value: remainingPv },
{ label: "Rang du lanceur", value: rank },
{ label: "Difficulte", value: item.system.difficulte ?? 0 },
{ label: "Effet", value: item.system.effet || "—" },
...(favor ? [
{ label: "Faveur", value: favor.label },
{ label: "Dé de faveur", value: favor.result },
{ label: "Avant", value: DonjonEtCieUtility.formatUsageDie(favor.before) },
{ label: "Apres", value: DonjonEtCieUtility.formatUsageDie(favor.after) }
] : [])
],
focusRolled: focus.rolled,
focusValue: focus.activeValue,
focusSpent,
focusRemaining,
focusBeforeLabel: DonjonEtCieUtility.formatUsageDie(focus.before),
focusAfterLabel: DonjonEtCieUtility.formatUsageDie(focus.after),
focusDegraded: focus.degraded,
spentPv,
remainingPv
});
return { ...result, success, spentPv, remainingPv, cost, focus, focusSpent, focusRemaining, favor, mode: effectiveMode };
}
static async rollSpellChaos(actor, item) {
const before = Number(actor?.system?.magie?.chaos?.delta ?? 12);
if (!before || before < 4) {
ui.notifications.warn("Le Chaos n'est pas disponible pour ce sort.");
return null;
}
const resolved = await this.#resolveFormulaRoll(`1d${before}`, {}, { favorable: "high" });
const result = resolved.kept;
const degraded = result <= 3;
const after = degraded ? DonjonEtCieUtility.degradeUsageDie(before) : before;
const chaosEntry = DONJON_ET_CIE.chaosTable[result] ?? null;
if (after !== before) {
await actor.update({ "system.magie.chaos.delta": after });
}
await this.#createChatCard(actor, "systems/fvtt-donjon-et-cie/templates/chat/chaos-card.hbs", {
title: `Chaos : ${item.name}`,
value: result,
before: DonjonEtCieUtility.formatUsageDie(before),
after: DonjonEtCieUtility.formatUsageDie(after),
chaosEntry,
degraded,
exhausted: after < 4,
itemName: item.name
});
return { result, before, after, degraded, chaosEntry };
}
static async rollUsage(item, { mode = "normal" } = {}) {
const before = Number(item.system.delta ?? 0);
if (!before) return null;
const resolved = await this.#resolveFormulaRoll(`1d${before}`, {}, { mode, favorable: "high" });
const result = resolved.kept;
const degraded = result <= 3;
const after = degraded ? DonjonEtCieUtility.degradeUsageDie(before) : before;
const updateData = {};
if (item.type === "armure") {
updateData["system.resultatProtection"] = result;
}
if (after !== before) {
updateData["system.delta"] = after;
}
if (Object.keys(updateData).length) {
await item.update(updateData);
}
await this.#createChatCard(item.actor, "systems/fvtt-donjon-et-cie/templates/chat/usage-card.hbs", {
title: `${game.i18n.localize("DNC.Roll.Usage")} : ${item.name}`,
value: result,
values: resolved.values,
mode: resolved.mode,
modeLabel: this.#getModeLabel(resolved.mode),
before: DonjonEtCieUtility.formatUsageDie(before),
after: DonjonEtCieUtility.formatUsageDie(after),
protectionStored: item.type === "armure" ? result : null,
degraded,
exhausted: after === 0
});
return { result, values: resolved.values, mode: resolved.mode, before, after, degraded };
}
}

View File

@@ -0,0 +1,189 @@
import { DONJON_ET_CIE } from "./donjon-et-cie-config.mjs";
export class DonjonEtCieUtility {
static defaultItemIcons = {
arme: "systems/fvtt-donjon-et-cie/assets/icons/system/items/arme.svg",
armure: "systems/fvtt-donjon-et-cie/assets/icons/system/items/armure.svg",
trait: "systems/fvtt-donjon-et-cie/assets/icons/system/items/trait.svg",
sortilege: "systems/fvtt-donjon-et-cie/assets/icons/system/items/sortilege.svg",
equipement: "systems/fvtt-donjon-et-cie/assets/icons/system/items/equipement.svg",
other: "systems/fvtt-donjon-et-cie/assets/icons/system/items/autre.svg"
};
static async preloadHandlebarsTemplates() {
return foundry.applications.handlebars.loadTemplates([
"systems/fvtt-donjon-et-cie/templates/actors/employe-sheet.hbs",
"systems/fvtt-donjon-et-cie/templates/actors/pnj-sheet.hbs",
"systems/fvtt-donjon-et-cie/templates/items/item-sheet.hbs",
"systems/fvtt-donjon-et-cie/templates/dialogs/characteristic-roll.hbs",
"systems/fvtt-donjon-et-cie/templates/dialogs/initiative-roll.hbs",
"systems/fvtt-donjon-et-cie/templates/dialogs/weapon-roll.hbs",
"systems/fvtt-donjon-et-cie/templates/dialogs/damage-roll.hbs",
"systems/fvtt-donjon-et-cie/templates/dialogs/spell-roll.hbs",
"systems/fvtt-donjon-et-cie/templates/dialogs/usage-roll.hbs",
"systems/fvtt-donjon-et-cie/templates/chat/roll-card.hbs",
"systems/fvtt-donjon-et-cie/templates/chat/spell-card.hbs",
"systems/fvtt-donjon-et-cie/templates/chat/chaos-card.hbs",
"systems/fvtt-donjon-et-cie/templates/chat/hit-dice-card.hbs",
"systems/fvtt-donjon-et-cie/templates/chat/damage-card.hbs",
"systems/fvtt-donjon-et-cie/templates/chat/damage-application-card.hbs",
"systems/fvtt-donjon-et-cie/templates/chat/favor-card.hbs",
"systems/fvtt-donjon-et-cie/templates/chat/initiative-card.hbs",
"systems/fvtt-donjon-et-cie/templates/chat/usage-card.hbs",
"systems/fvtt-donjon-et-cie/templates/chat/item-card.hbs"
]);
}
static getCharacteristicEntries(system) {
return Object.entries(DONJON_ET_CIE.characteristics).map(([key, metadata]) => ({
key,
label: metadata.label,
short: metadata.short,
value: system.caracteristiques?.[key]?.value ?? 0
}));
}
static formatUsageDie(value) {
return value ? `Δ${value}` : "—";
}
static getDefaultItemIcon(type) {
return this.defaultItemIcons[type] ?? this.defaultItemIcons.other;
}
static getCurrentSceneId() {
return canvas?.scene?.id ?? game.scenes?.current?.id ?? "global";
}
static getSceneDamageTargets() {
const scene = canvas?.scene ?? game.scenes?.current;
const tokens = scene?.tokens?.contents ?? [];
return tokens
.map((token) => {
const actor = token.actor;
if (!actor || !["employe", "pnj"].includes(actor.type)) return null;
const tokenName = token.name || actor.name;
const actorName = actor.name || tokenName;
const label = tokenName === actorName ? tokenName : `${tokenName} (${actorName})`;
return {
tokenId: token.id,
tokenUuid: token.uuid,
actorUuid: actor.uuid,
label
};
})
.filter(Boolean)
.sort((a, b) => a.label.localeCompare(b.label, "fr", { sensitivity: "base" }));
}
static getMagicResourceContext(actor) {
const rank = Number(actor.system.anciennete?.rang ?? actor.system.sante?.dv ?? 0);
const focusDelta = Number(actor.system.magie?.focus?.delta ?? 0);
const focusResult = Number(actor.system.magie?.focus?.resultat ?? 0);
const focusSceneId = actor.system.magie?.focus?.sceneId ?? "";
const activeFocus = focusSceneId === this.getCurrentSceneId() ? focusResult : 0;
const chaosDelta = Number(actor.system.magie?.chaos?.delta ?? 12);
return {
rank,
focusDelta,
focusLabel: this.formatUsageDie(focusDelta),
focusSceneId,
focusStoredResult: focusResult,
focusActiveValue: activeFocus,
focusIsActive: activeFocus > 0,
focusDisplay: activeFocus > 0 ? `${activeFocus} (${this.formatUsageDie(focusDelta)})` : "—",
chaosDelta,
chaosLabel: this.formatUsageDie(chaosDelta),
chaosAvailable: chaosDelta >= 4
};
}
static getFavorLabel(key) {
return DONJON_ET_CIE.favorDepartments[key] ?? key;
}
static getFavorEntries(system) {
const favors = system.faveurs ?? {};
return Object.entries(DONJON_ET_CIE.favorDepartments).map(([key, label]) => {
const delta = Number(favors[key]?.delta ?? 0);
return {
key,
label,
delta,
deltaLabel: this.formatUsageDie(delta),
hasFavor: delta > 0
};
});
}
static getAvailableFavorOptions(actor) {
return this.getFavorEntries(actor.system)
.filter((entry) => entry.hasFavor)
.map((entry) => ({ value: entry.key, label: `${entry.label} (${entry.deltaLabel})` }));
}
static getChaosTableEntries() {
return Object.entries(DONJON_ET_CIE.chaosTable)
.map(([value, entry]) => ({ value: Number(value), ...entry }))
.sort((a, b) => a.value - b.value);
}
static degradeUsageDie(value) {
const sequence = [12, 10, 8, 6, 4];
const index = sequence.indexOf(Number(value));
if (index === -1) return 0;
return sequence[index + 1] ?? 0;
}
static sortByName(documents) {
return [...documents].sort((a, b) => a.name.localeCompare(b.name, "fr", { sensitivity: "base" }));
}
static getWeaponCharacteristicKey(category) {
return category === "distance" ? "dexterite" : "force";
}
static getWeaponCharacteristicLabel(category) {
const key = this.getWeaponCharacteristicKey(category);
return DONJON_ET_CIE.characteristics[key]?.label ?? key;
}
static enrichItemForSheet(item) {
const system = item.system;
const delta = Number(system.delta ?? 0);
return {
id: item.id,
name: item.name,
type: item.type,
img: item.img,
system,
uuid: item.uuid,
usageLabel: delta > 0 ? this.formatUsageDie(delta) : null,
protectionLabel: item.type === "armure" && Number(system.resultatProtection ?? 0) > 0 ? `Protection ${system.resultatProtection}` : null,
weaponCharacteristicLabel: item.type === "arme" ? this.getWeaponCharacteristicLabel(system.categorie) : null,
canRoll: ["arme", "sortilege"].includes(item.type),
canUse: delta > 0,
canRollDamage: Boolean(system.degats),
rollAction: item.type === "sortilege" ? "rollSpell" : "rollWeapon",
damageAction: "rollDamage",
isEquipped: Boolean(system.equipee)
};
}
static buildActorSections(actor) {
return Object.entries(DONJON_ET_CIE.actorSections).map(([key, metadata]) => {
const types = DONJON_ET_CIE.sectionTypes[key];
const items = this.sortByName(actor.items.filter((item) => types.includes(item.type))).map((item) => this.enrichItemForSheet(item));
return {
key,
label: metadata.label,
createType: metadata.createType,
items
};
});
}
}

16
modules/models/arme.mjs Normal file
View File

@@ -0,0 +1,16 @@
import BaseItemDataModel from "./base-item.mjs";
export default class ArmeDataModel extends BaseItemDataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
...super.defineSchema(),
categorie: new fields.StringField({ initial: "melee" }),
caracteristique: new fields.StringField({ initial: "force" }),
degats: new fields.StringField({ initial: "1d6" }),
portee: new fields.StringField({ initial: "" }),
mains: new fields.NumberField({ initial: 1, integer: true }),
equipee: new fields.BooleanField({ initial: false })
};
}
}

14
modules/models/armure.mjs Normal file
View File

@@ -0,0 +1,14 @@
import BaseItemDataModel from "./base-item.mjs";
export default class ArmureDataModel extends BaseItemDataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
...super.defineSchema(),
delta: new fields.NumberField({ initial: 8, integer: true }),
resultatProtection: new fields.NumberField({ initial: 0, integer: true }),
equipee: new fields.BooleanField({ initial: false }),
encombrement: new fields.StringField({ initial: "" })
};
}
}

View File

@@ -0,0 +1,9 @@
export default class BaseItemDataModel extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
description: new fields.HTMLField({ initial: "" }),
notes: new fields.HTMLField({ initial: "" })
};
}
}

View File

@@ -0,0 +1,12 @@
import BaseItemDataModel from "./base-item.mjs";
export default class CapaciteDataModel extends BaseItemDataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
...super.defineSchema(),
cout: new fields.StringField({ initial: "" }),
effet: new fields.StringField({ initial: "" })
};
}
}

View File

@@ -0,0 +1,13 @@
import BaseItemDataModel from "./base-item.mjs";
export default class ConsommableDataModel extends BaseItemDataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
...super.defineSchema(),
quantite: new fields.NumberField({ initial: 1, integer: true }),
delta: new fields.NumberField({ initial: 6, integer: true }),
effet: new fields.StringField({ initial: "" })
};
}
}

View File

@@ -0,0 +1,66 @@
import { DONJON_ET_CIE } from "../donjon-et-cie-config.mjs";
export default class EmployeDataModel extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields;
const makeCharacteristic = (label, short) => new fields.SchemaField({
label: new fields.StringField({ initial: label }),
short: new fields.StringField({ initial: short }),
value: new fields.NumberField({ initial: 10, integer: true })
});
const favorFields = Object.fromEntries(Object.keys(DONJON_ET_CIE.favorDepartments).map((key) => [
key,
new fields.SchemaField({
delta: new fields.NumberField({ initial: 0, integer: true })
})
]));
return {
concept: new fields.StringField({ initial: "" }),
anciennete: new fields.SchemaField({
rang: new fields.NumberField({ initial: 1, integer: true }),
libelle: new fields.StringField({ initial: "Nouvel employe" })
}),
caracteristiques: new fields.SchemaField({
force: makeCharacteristic("FORce", "FOR"),
dexterite: makeCharacteristic("DEXterite", "DEX"),
constitution: makeCharacteristic("CONstitution", "CON"),
intelligence: makeCharacteristic("INTelligence", "INT"),
sagesse: makeCharacteristic("SAGesse", "SAG"),
charisme: makeCharacteristic("CHArisme", "CHA")
}),
sante: new fields.SchemaField({
dv: new fields.StringField({ initial: "1d6" }),
pv: new fields.SchemaField({
value: new fields.NumberField({ initial: 6, integer: true }),
max: new fields.NumberField({ initial: 6, integer: true })
})
}),
combat: new fields.SchemaField({
initiativeBonus: new fields.NumberField({ initial: 0, integer: true }),
degatsBonus: new fields.NumberField({ initial: 0, integer: true }),
attaquesCorpsACorps: new fields.NumberField({ initial: 1, integer: true }),
attaquesDistance: new fields.NumberField({ initial: 1, integer: true })
}),
magie: new fields.SchemaField({
focus: new fields.SchemaField({
delta: new fields.NumberField({ initial: 0, integer: true }),
resultat: new fields.NumberField({ initial: 0, integer: true }),
sceneId: new fields.StringField({ initial: "" })
}),
chaos: new fields.SchemaField({
delta: new fields.NumberField({ initial: 12, integer: true })
})
}),
profil: new fields.SchemaField({
objectifPersonnel: new fields.HTMLField({ initial: "" }),
suspicion: new fields.NumberField({ initial: 0, integer: true }),
avertissements: new fields.NumberField({ initial: 0, integer: true }),
missionsReussies: new fields.NumberField({ initial: 0, integer: true })
}),
faveurs: new fields.SchemaField(favorFields),
notes: new fields.HTMLField({ initial: "" }),
gmnotes: new fields.HTMLField({ initial: "" })
};
}
}

View File

@@ -0,0 +1,13 @@
import BaseItemDataModel from "./base-item.mjs";
export default class EquipementDataModel extends BaseItemDataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
...super.defineSchema(),
quantite: new fields.NumberField({ initial: 1, integer: true }),
equipee: new fields.BooleanField({ initial: false }),
emplacement: new fields.StringField({ initial: "" })
};
}
}

11
modules/models/index.mjs Normal file
View File

@@ -0,0 +1,11 @@
export { default as BaseItemDataModel } from "./base-item.mjs";
export { default as TraitDataModel } from "./trait.mjs";
export { default as LangueDataModel } from "./langue.mjs";
export { default as CapaciteDataModel } from "./capacite.mjs";
export { default as SortilegeDataModel } from "./sortilege.mjs";
export { default as ArmeDataModel } from "./arme.mjs";
export { default as ArmureDataModel } from "./armure.mjs";
export { default as EquipementDataModel } from "./equipement.mjs";
export { default as ConsommableDataModel } from "./consommable.mjs";
export { default as EmployeDataModel } from "./employe.mjs";
export { default as PnjDataModel } from "./pnj.mjs";

11
modules/models/langue.mjs Normal file
View File

@@ -0,0 +1,11 @@
import BaseItemDataModel from "./base-item.mjs";
export default class LangueDataModel extends BaseItemDataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
...super.defineSchema(),
niveau: new fields.StringField({ initial: "courant" })
};
}
}

36
modules/models/pnj.mjs Normal file
View File

@@ -0,0 +1,36 @@
export default class PnjDataModel extends foundry.abstract.TypeDataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
espece: new fields.StringField({ initial: "" }),
categorie: new fields.StringField({ initial: "Resident" }),
role: new fields.StringField({ initial: "" }),
resume: new fields.StringField({ initial: "" }),
sante: new fields.SchemaField({
dv: new fields.StringField({ initial: "1d8" }),
pv: new fields.SchemaField({
value: new fields.NumberField({ initial: 6, integer: true }),
max: new fields.NumberField({ initial: 6, integer: true })
})
}),
defense: new fields.SchemaField({
armure: new fields.SchemaField({
delta: new fields.NumberField({ initial: 0, integer: true }),
resultatProtection: new fields.NumberField({ initial: 0, integer: true })
}),
courage: new fields.SchemaField({
delta: new fields.NumberField({ initial: 0, integer: true })
})
}),
attaque: new fields.SchemaField({
nom: new fields.StringField({ initial: "Attaque" }),
degats: new fields.StringField({ initial: "1d6" }),
notes: new fields.StringField({ initial: "" })
}),
pouvoirsSpeciaux: new fields.HTMLField({ initial: "" }),
description: new fields.HTMLField({ initial: "" }),
notes: new fields.HTMLField({ initial: "" })
};
}
}

View File

@@ -0,0 +1,17 @@
import BaseItemDataModel from "./base-item.mjs";
export default class SortilegeDataModel extends BaseItemDataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
...super.defineSchema(),
caracteristique: new fields.StringField({ initial: "intelligence" }),
difficulte: new fields.NumberField({ initial: 0, integer: true }),
coutPv: new fields.NumberField({ initial: 0, integer: true }),
portee: new fields.StringField({ initial: "" }),
duree: new fields.StringField({ initial: "" }),
effet: new fields.StringField({ initial: "" }),
degats: new fields.StringField({ initial: "" })
};
}
}

11
modules/models/trait.mjs Normal file
View File

@@ -0,0 +1,11 @@
import BaseItemDataModel from "./base-item.mjs";
export default class TraitDataModel extends BaseItemDataModel {
static defineSchema() {
const fields = foundry.data.fields;
return {
...super.defineSchema(),
etiquette: new fields.StringField({ initial: "" })
};
}
}