Files
fvtt-oath-hammer/module/applications/sheets/character-sheet.mjs
LeRatierBretonnier b3fd7e1aa1 feat: add Settlement actor type with Overview/Buildings/Inventory tabs
- New TypeDataModel: archetype, territory, renown, currency (gp/sp/cp),
  garrison, underSiege, isCapital, founded, taxNotes, description, notes
- 3-tab ApplicationV2 sheet with drag & drop for building/weapon/armor/equipment
- Currency steppers (+/−), building constructed toggle, qty controls
- LESS-based CSS (settlement-sheet.less) + base.less updated for shared styles
- Full i18n keys in lang/en.json (8 settlement archetypes)
- system.json: registered settlement actor type

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 17:01:38 +01:00

429 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import OathHammerActorSheet from "./base-actor-sheet.mjs"
import { SYSTEM } from "../../config/system.mjs"
import OathHammerRollDialog from "../roll-dialog.mjs"
import OathHammerWeaponDialog from "../weapon-dialog.mjs"
import OathHammerSpellDialog from "../spell-dialog.mjs"
import OathHammerMiracleDialog from "../miracle-dialog.mjs"
import OathHammerDefenseDialog from "../defense-dialog.mjs"
import OathHammerArmorDialog from "../armor-dialog.mjs"
import { rollSkillCheck, rollWeaponAttack, rollWeaponDamage, rollSpellCast, rollMiracleCast, rollArmorSave, rollWeaponDefense, rollInitiativeCheck } from "../../rolls.mjs"
export default class OathHammerCharacterSheet extends OathHammerActorSheet {
/** @override */
static DEFAULT_OPTIONS = {
classes: ["character"],
position: {
width: 972,
height: 780,
},
window: {
contentClasses: ["character-content"],
},
actions: {
createWeapon: OathHammerCharacterSheet.#onCreateWeapon,
createSpell: OathHammerCharacterSheet.#onCreateSpell,
createMiracle: OathHammerCharacterSheet.#onCreateMiracle,
createEquipment: OathHammerCharacterSheet.#onCreateEquipment,
createTrait: OathHammerCharacterSheet.#onCreateTrait,
createOath: OathHammerCharacterSheet.#onCreateOath,
rollSkill: OathHammerCharacterSheet.#onRollSkill,
attackWeapon: OathHammerCharacterSheet.#onAttackWeapon,
defendWeapon: OathHammerCharacterSheet.#onDefendWeapon,
damageWeapon: OathHammerCharacterSheet.#onDamageWeapon,
castSpell: OathHammerCharacterSheet.#onCastSpell,
castMiracle: OathHammerCharacterSheet.#onCastMiracle,
rollArmorSave: OathHammerCharacterSheet.#onRollArmorSave,
resetMiracleBlocked: OathHammerCharacterSheet.#onResetMiracleBlocked,
rollInitiative: OathHammerCharacterSheet.#onRollInitiative,
adjustQty: OathHammerCharacterSheet.#onAdjustQty,
adjustCurrency: OathHammerCharacterSheet.#onAdjustCurrency,
adjustStress: OathHammerCharacterSheet.#onAdjustStress,
clearStress: OathHammerCharacterSheet.#onClearStress,
},
}
/** @override */
static PARTS = {
main: { template: "systems/fvtt-oath-hammer/templates/actor/character-sheet.hbs" },
tabs: { template: "templates/generic/tab-navigation.hbs" },
identity: { template: "systems/fvtt-oath-hammer/templates/actor/character-identity.hbs" },
skills: { template: "systems/fvtt-oath-hammer/templates/actor/character-skills.hbs" },
combat: { template: "systems/fvtt-oath-hammer/templates/actor/character-combat.hbs" },
magic: { template: "systems/fvtt-oath-hammer/templates/actor/character-magic.hbs" },
equipment: { template: "systems/fvtt-oath-hammer/templates/actor/character-equipment.hbs" },
notes: { template: "systems/fvtt-oath-hammer/templates/actor/character-notes.hbs" },
}
/** @override */
tabGroups = {
sheet: "identity",
}
#getTabs() {
const tabs = {
identity: { id: "identity", group: "sheet", icon: "fa-solid fa-person", label: "OATHHAMMER.Tab.Identity" },
skills: { id: "skills", group: "sheet", icon: "fa-solid fa-scroll", label: "OATHHAMMER.Tab.Skills" },
combat: { id: "combat", group: "sheet", icon: "fa-solid fa-swords", label: "OATHHAMMER.Tab.Combat" },
magic: { id: "magic", group: "sheet", icon: "fa-solid fa-wand-magic-sparkles", label: "OATHHAMMER.Tab.Magic" },
equipment: { id: "equipment", group: "sheet", icon: "fa-solid fa-backpack", label: "OATHHAMMER.Tab.Equipment" },
notes: { id: "notes", group: "sheet", icon: "fa-solid fa-book", label: "OATHHAMMER.Tab.Notes" },
}
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()
// class/experience available to all parts (header + identity tab)
const doc = this.document
context.characterClass = doc.itemTypes["class"]?.[0] ?? null
return context
}
/** @override */
async _preparePartContext(partId, context) {
const doc = this.document
switch (partId) {
case "main":
break
case "identity":
context.tab = context.tabs.identity
context.traits = doc.itemTypes.trait.map(a => {
const typeKey = SYSTEM.TRAIT_TYPE_CHOICES[a.system.traitType]
const periodKey = SYSTEM.TRAIT_USAGE_PERIOD[a.system.usagePeriod]
const isPassive = a.system.usagePeriod === "none"
return {
id: a.id, uuid: a.uuid, img: a.img, name: a.name, system: a.system,
_typeLabel: typeKey ? game.i18n.localize(typeKey) : a.system.traitType,
_usageLabel: isPassive
? game.i18n.localize("OATHHAMMER.UsagePeriod.None")
: `${a.system.maxUses > 0 ? a.system.maxUses + " / " : ""}${game.i18n.localize(periodKey)}`,
_descTooltip: _stripHtml(a.system.description)
}
})
context.oaths = doc.itemTypes.oath.map(o => {
const typeEntry = SYSTEM.OATH_TYPES[o.system.oathType]
const parts = [o.system.tenet, o.system.boon, o.system.bane].filter(Boolean)
return {
id: o.id, uuid: o.uuid, img: o.img, name: o.name, system: o.system,
_typeLabel: typeEntry ? game.i18n.localize(typeEntry.label) : o.system.oathType,
_violated: o.system.violated,
_boonText: _stripHtml(o.system.boon, 80),
_baneText: _stripHtml(o.system.bane, 80),
_descTooltip: _stripHtml(parts.join(" "))
}
})
context.enrichedBackground = await foundry.applications.ux.TextEditor.implementation.enrichHTML(doc.system.background ?? "", { async: true })
break
case "skills": {
context.tab = context.tabs.skills
const sys = doc.system
const skillSchemaFields = doc.system.schema.fields.skills.fields
const attrRanks = {
might: sys.attributes.might.rank,
toughness: sys.attributes.toughness.rank,
agility: sys.attributes.agility.rank,
willpower: sys.attributes.willpower.rank,
intelligence: sys.attributes.intelligence.rank,
fate: sys.attributes.fate.rank,
}
context.skillGroups = Object.entries(SYSTEM.SKILLS_BY_ATTRIBUTE).map(([attr, skillKeys]) => ({
attribute: attr,
label: `OATHHAMMER.Attribute.${attr.charAt(0).toUpperCase()}${attr.slice(1)}`,
attrRank: attrRanks[attr],
skillData: skillKeys.map(skillKey => {
const sk = sys.skills[skillKey]
return {
key: skillKey,
label: SYSTEM.SKILLS[skillKey].label,
rank: sk.rank,
modifier: sk.modifier,
colorDice: sk.colorDice,
colorDiceType: sk.colorDiceType,
rankName: `system.skills.${skillKey}.rank`,
modifierName: `system.skills.${skillKey}.modifier`,
colorDiceName: `system.skills.${skillKey}.colorDice`,
colorDiceTypeName: `system.skills.${skillKey}.colorDiceType`,
rankOptions: [0,1,2,3,4].map(v => ({ value: v, label: String(v), selected: v === sk.rank })),
total: attrRanks[attr] + sk.rank,
// legacy - kept for formInput compatibility
name: `system.skills.${skillKey}.rank`,
field: skillSchemaFields[skillKey].fields.rank,
}
})
}))
break
}
case "combat":
context.tab = context.tabs.combat
context.weapons = doc.itemTypes.weapon.map(w => {
const groupKey = SYSTEM.WEAPON_PROFICIENCY_GROUPS[w.system.proficiencyGroup]
const traitsLabel = [...w.system.traits].map(t => {
const tk = SYSTEM.WEAPON_TRAITS[t]
return tk ? game.i18n.localize(tk) : t
}).join(", ")
return {
id: w.id, uuid: w.uuid, img: w.img, name: w.name, system: w.system,
_groupLabel: groupKey ? game.i18n.localize(groupKey) : w.system.proficiencyGroup,
_traitsTooltip: traitsLabel || null,
_descTooltip: _stripHtml(w.system.description),
_isMagic: w.system.isMagic
}
})
context.armors = doc.itemTypes.armor.map(a => {
const typeKey = SYSTEM.ARMOR_TYPE_CHOICES[a.system.armorType]
const traitsLabel = [...a.system.traits].map(t => {
const tk = SYSTEM.ARMOR_TRAITS[t]
return tk ? game.i18n.localize(tk) : t
}).join(", ")
return {
id: a.id, uuid: a.uuid, img: a.img, name: a.name, system: a.system,
_typeLabel: typeKey ? game.i18n.localize(typeKey) : a.system.armorType,
_traitsTooltip: traitsLabel || null,
_descTooltip: _stripHtml(a.system.description),
_isMagic: a.system.isMagic
}
})
context.ammunition = doc.itemTypes.ammunition
// Slot tracking: max = 10 + (Might rank × 2); used = sum of all items' slots × quantity
context.slotsMax = 10 + (doc.system.attributes.might.rank * 2)
context.slotsUsed = doc.items.reduce((sum, item) => {
const qty = item.system.quantity ?? 1
return sum + (item.system.slots ?? 0) * Math.max(qty, 1)
}, 0)
context.slotsOver = context.slotsUsed > context.slotsMax
// Show current initiative score if actor is in an active combat
const combatant = game.combat?.combatants.find(c => c.actor?.id === doc.id)
context.combatantInitiative = combatant?.initiative ?? null
break
case "magic":
context.tab = context.tabs.magic
context.stressBlocked = doc.system.arcaneStress.value >= doc.system.arcaneStress.threshold
context.spells = doc.itemTypes.spell.map(s => ({
id: s.id, uuid: s.uuid, img: s.img, name: s.name, system: s.system,
_descTooltip: _stripHtml(s.system.effect)
}))
context.miracles = doc.itemTypes.miracle.map(m => ({
id: m.id, uuid: m.uuid, img: m.img, name: m.name, system: m.system,
_descTooltip: _stripHtml(m.system.effect)
}))
break
case "equipment":
context.tab = context.tabs.equipment
context.equipment = doc.itemTypes.equipment.map(e => ({
id: e.id, uuid: e.uuid, img: e.img, name: e.name, system: e.system,
_descTooltip: _stripHtml(e.system.description)
}))
context.magicItems = doc.itemTypes["magic-item"].map(m => ({
id: m.id, uuid: m.uuid, img: m.img, name: m.name, system: m.system,
_descTooltip: _stripHtml(m.system.description)
}))
context.slotsMax = 10 + (doc.system.attributes.might.rank * 2)
context.slotsUsed = doc.items.reduce((sum, item) => {
const qty = item.system.quantity ?? 1
return sum + (item.system.slots ?? 0) * Math.max(qty, 1)
}, 0)
context.slotsOver = context.slotsUsed > context.slotsMax
break
case "notes":
context.tab = context.tabs.notes
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
}
/** Auto-fill colorDice count when color type changes */
static #COLOR_THRESHOLDS = { white: 4, red: 3, black: 2 }
_onRender(context, options) {
super._onRender?.(context, options)
// Color dice auto-fill
this.element.querySelectorAll('select.color-dice-select').forEach(select => {
select.addEventListener('change', event => {
const threshold = OathHammerCharacterSheet.#COLOR_THRESHOLDS[event.target.value] ?? 4
const countInput = event.target.closest('.skill-color-col')?.querySelector('input[type="number"]')
if (countInput) {
countInput.value = threshold
countInput.dispatchEvent(new Event('change', { bubbles: true }))
}
const dot = event.target.closest('.skill-color-col')?.querySelector('.color-dice-dot')
if (dot) dot.className = `color-dice-dot color-dice-${event.target.value}`
})
})
// Equipped checkbox — directly updates the item
this.element.querySelectorAll('input.item-equipped-cb').forEach(cb => {
cb.addEventListener('change', event => {
const itemId = event.target.dataset.itemId
const item = this.document.items.get(itemId)
if (item) item.update({ 'system.equipped': event.target.checked })
})
})
}
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)
return this._onDropItem(item)
}
}
static #onCreateWeapon(event, target) {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Weapon"), type: "weapon" }])
}
static #onCreateSpell(event, target) {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Spell"), type: "spell" }])
}
static #onCreateMiracle(event, target) {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Miracle"), type: "miracle" }])
}
static #onCreateEquipment(event, target) {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Equipment"), type: "equipment" }])
}
static #onCreateTrait(event, target) {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Trait"), type: "trait" }])
}
static #onCreateOath(event, target) {
this.document.createEmbeddedDocuments("Item", [{ name: game.i18n.localize("OATHHAMMER.NewItem.Oath"), type: "oath" }])
}
static async #onRollSkill(event, target) {
const skillKey = target.dataset.skill
if (!skillKey) return
const result = await OathHammerRollDialog.prompt(this.document, skillKey)
if (!result) return
await rollSkillCheck(this.document, skillKey, result.dv, result)
}
static async #onAttackWeapon(event, target) {
const weaponId = target.dataset.itemId
if (!weaponId) return
const weapon = this.document.items.get(weaponId)
if (!weapon) return
const opts = await OathHammerWeaponDialog.promptAttack(this.document, weapon)
if (!opts) return
await rollWeaponAttack(this.document, weapon, opts)
}
static async #onDefendWeapon(event, target) {
const weaponId = target.dataset.itemId
if (!weaponId) return
const weapon = this.document.items.get(weaponId)
if (!weapon) return
const opts = await OathHammerWeaponDialog.promptDefense(this.document, weapon)
if (!opts) return
await rollWeaponDefense(this.document, weapon, opts)
}
static async #onDamageWeapon(event, target) {
const weaponId = target.dataset.itemId
if (!weaponId) return
const weapon = this.document.items.get(weaponId)
if (!weapon) return
const opts = await OathHammerWeaponDialog.promptDamage(this.document, weapon, 0)
if (!opts) return
await rollWeaponDamage(this.document, weapon, opts)
}
static async #onCastSpell(event, target) {
const spellId = target.dataset.itemId
if (!spellId) return
const spell = this.document.items.get(spellId)
if (!spell) return
const opts = await OathHammerSpellDialog.prompt(this.document, spell)
if (!opts) return
await rollSpellCast(this.document, spell, opts)
}
static async #onCastMiracle(event, target) {
const miracleId = target.dataset.itemId
if (!miracleId) return
const miracle = this.document.items.get(miracleId)
if (!miracle) return
if (this.document.system.miracleBlocked) {
ui.notifications.warn(game.i18n.localize("OATHHAMMER.Warning.MiracleBlocked"))
return
}
const opts = await OathHammerMiracleDialog.prompt(this.document, miracle)
if (!opts) return
await rollMiracleCast(this.document, miracle, opts)
}
static async #onResetMiracleBlocked() {
await this.document.update({ "system.miracleBlocked": false })
}
static async #onRollArmorSave(event, target) {
const armorId = target.dataset.itemId
if (!armorId) return
const armor = this.document.items.get(armorId)
if (!armor) return
const opts = await OathHammerArmorDialog.prompt(this.document, armor)
if (!opts) return
await rollArmorSave(this.document, armor, opts)
}
static async #onRollInitiative() {
const actor = this.document
const combatant = game.combat?.combatants.find(c => c.actor?.id === actor.id)
if (combatant) {
// Delegate to OathHammerCombat.rollInitiative — posts to chat and updates tracker
await game.combat.rollInitiative([combatant.id])
} else {
// Not in combat — roll for display only
await rollInitiativeCheck(actor)
}
}
static async #onAdjustQty(event, target) {
const itemId = target.dataset.itemId
const delta = parseInt(target.dataset.delta, 10)
if (!itemId || isNaN(delta)) return
const item = this.document.items.get(itemId)
if (!item) return
const current = item.system.quantity ?? 0
await item.update({ "system.quantity": Math.max(0, current + delta) })
}
static async #onAdjustCurrency(event, target) {
const field = target.dataset.field
const delta = parseInt(target.dataset.delta, 10)
if (!field || isNaN(delta)) return
const current = foundry.utils.getProperty(this.document, field) ?? 0
await this.document.update({ [field]: Math.max(0, current + delta) })
}
static async #onAdjustStress(event, target) {
const delta = parseInt(target.dataset.delta, 10)
const current = this.document.system.arcaneStress.value ?? 0
const max = this.document.system.arcaneStress.threshold
await this.document.update({ "system.arcaneStress.value": Math.max(0, Math.min(max, current + delta)) })
}
static async #onClearStress(_event, _target) {
await this.document.update({ "system.arcaneStress.value": 0 })
}
}
/** Strip HTML tags and collapse whitespace for use in data-tooltip attributes. */
function _stripHtml(html, maxLen = 300) {
if (!html) return ""
const text = html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim()
return text.length > maxLen ? text.slice(0, maxLen - 1) + "…" : text
}