System development, WIP
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
export { default as MGNEActorSheet } from "./sheets/base-actor-sheet.mjs"
|
||||
export { default as MGNEItemSheet } from "./sheets/base-item-sheet.mjs"
|
||||
export { default as MGNECharacterSheet } from "./sheets/character-sheet.mjs"
|
||||
export { default as MGNECreatureSheet } from "./sheets/creature-sheet.mjs"
|
||||
export { default as MGNECompanionSheet } from "./sheets/companion-sheet.mjs"
|
||||
export { default as MGNEWeaponSheet } from "./sheets/weapon-sheet.mjs"
|
||||
export { default as MGNEArmorSheet } from "./sheets/armor-sheet.mjs"
|
||||
export { default as MGNEShieldSheet } from "./sheets/shield-sheet.mjs"
|
||||
export { default as MGNEEquipmentSheet } from "./sheets/equipment-sheet.mjs"
|
||||
export { default as MGNEResonanceCoreSheet } from "./sheets/resonance-core-sheet.mjs"
|
||||
export { default as MGNEArtifactSheet } from "./sheets/artifact-sheet.mjs"
|
||||
export { default as MGNEFeatureSheet } from "./sheets/feature-sheet.mjs"
|
||||
@@ -0,0 +1,7 @@
|
||||
import MGNEItemSheet from "./base-item-sheet.mjs"
|
||||
|
||||
export default class MGNEArmorSheet extends MGNEItemSheet {
|
||||
static PARTS = {
|
||||
main: { template: "systems/fvtt-machine-gods-noxian-expanse/templates/armor.hbs" },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import MGNEItemSheet from "./base-item-sheet.mjs"
|
||||
|
||||
export default class MGNEArtifactSheet extends MGNEItemSheet {
|
||||
static PARTS = {
|
||||
main: { template: "systems/fvtt-machine-gods-noxian-expanse/templates/artifact.hbs" },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { SYSTEM } from "../../config/system.mjs"
|
||||
import { buildSharedSelectOptions } from "./select-options.mjs"
|
||||
import { enrichHTMLFields } from "./rich-text.mjs"
|
||||
|
||||
const { HandlebarsApplicationMixin } = foundry.applications.api
|
||||
|
||||
export default class MGNEActorSheet extends HandlebarsApplicationMixin(foundry.applications.sheets.ActorSheetV2) {
|
||||
static DEFAULT_OPTIONS = {
|
||||
classes: ["mgne", "actor-sheet"],
|
||||
position: {
|
||||
width: 900,
|
||||
height: "auto",
|
||||
},
|
||||
window: {
|
||||
resizable: true,
|
||||
},
|
||||
form: {
|
||||
submitOnChange: true,
|
||||
},
|
||||
actions: {
|
||||
editImage: MGNEActorSheet.onEditImage,
|
||||
changeTab: MGNEActorSheet.onChangeTab,
|
||||
createItem: MGNEActorSheet.onCreateItem,
|
||||
editItem: MGNEActorSheet.onEditItem,
|
||||
deleteItem: MGNEActorSheet.onDeleteItem,
|
||||
toggleEquipped: MGNEActorSheet.onToggleEquipped,
|
||||
syncArtifact: MGNEActorSheet.onSyncArtifact,
|
||||
resetDaily: MGNEActorSheet.onResetDaily,
|
||||
rollResonancePerDay: MGNEActorSheet.onRollResonancePerDay,
|
||||
quickRest: MGNEActorSheet.onQuickRest,
|
||||
fullRest: MGNEActorSheet.onFullRest,
|
||||
},
|
||||
}
|
||||
|
||||
async _prepareContext() {
|
||||
const systemFields = this.document.system.schema.fields
|
||||
|
||||
return {
|
||||
fields: this.document.schema.fields,
|
||||
systemFields,
|
||||
actor: this.document,
|
||||
system: this.document.system,
|
||||
source: this.document.toObject(),
|
||||
config: SYSTEM,
|
||||
enrichedFields: await enrichHTMLFields(this.document.system, systemFields),
|
||||
isEditable: this.isEditable,
|
||||
selectOptions: buildSharedSelectOptions(),
|
||||
}
|
||||
}
|
||||
|
||||
_onRender(context, options) {
|
||||
super._onRender?.(context, options)
|
||||
this.element.querySelectorAll(".rollable").forEach(element => {
|
||||
element.addEventListener("click", this._onRoll.bind(this))
|
||||
})
|
||||
}
|
||||
|
||||
async _onRoll(event) {
|
||||
const target = event.currentTarget
|
||||
const itemId = target.closest("[data-item-id]")?.dataset.itemId
|
||||
const rollType = target.dataset.rollType
|
||||
|
||||
switch (rollType) {
|
||||
case "ability":
|
||||
return this.document.rollAbility(target.dataset.abilityId)
|
||||
case "defense":
|
||||
return this.document.rollDefense()
|
||||
case "weapon":
|
||||
return this.document.rollWeapon(itemId)
|
||||
case "profile-attack":
|
||||
return this.document.rollProfileAttack()
|
||||
case "profile-damage":
|
||||
return this.document.rollProfileDamage()
|
||||
case "damage":
|
||||
return this.document.rollDamage(itemId)
|
||||
case "resonation":
|
||||
return this.document.rollResonation(itemId)
|
||||
case "morale":
|
||||
return this.document.rollMorale()
|
||||
case "usage":
|
||||
return this.document.rollUsage(itemId)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
static async onEditImage(_event, target) {
|
||||
const attr = target.dataset.edit
|
||||
const current = foundry.utils.getProperty(this.document, attr)
|
||||
const picker = new FilePicker({
|
||||
current,
|
||||
type: "image",
|
||||
callback: path => this.document.update({ [attr]: path }),
|
||||
top: this.position.top + 40,
|
||||
left: this.position.left + 10,
|
||||
})
|
||||
return picker.browse()
|
||||
}
|
||||
|
||||
static onChangeTab(_event, target) {
|
||||
const group = target.dataset.group
|
||||
const tab = target.dataset.tab
|
||||
this.tabGroups[group] = tab
|
||||
this.render()
|
||||
}
|
||||
|
||||
static async onCreateItem(_event, target) {
|
||||
const itemType = target.dataset.itemType
|
||||
const typeLabel = SYSTEM.itemTypes[itemType]?.label ?? itemType
|
||||
const [created] = await this.document.createEmbeddedDocuments("Item", [{
|
||||
name: game.i18n.format("MGNE.Common.NewItem", { type: typeLabel }),
|
||||
type: itemType,
|
||||
img: SYSTEM.itemTypes[itemType]?.icon,
|
||||
}])
|
||||
created?.sheet?.render(true)
|
||||
}
|
||||
|
||||
static async onEditItem(_event, target) {
|
||||
const itemId = target.closest("[data-item-id]")?.dataset.itemId
|
||||
const item = this.document.items.get(itemId)
|
||||
return item?.sheet?.render(true)
|
||||
}
|
||||
|
||||
static async onDeleteItem(_event, target) {
|
||||
const itemId = target.closest("[data-item-id]")?.dataset.itemId
|
||||
const item = this.document.items.get(itemId)
|
||||
if (!item) return null
|
||||
return item.delete()
|
||||
}
|
||||
|
||||
static async onToggleEquipped(_event, target) {
|
||||
const itemId = target.closest("[data-item-id]")?.dataset.itemId
|
||||
return this.document.toggleItemEquipped(itemId)
|
||||
}
|
||||
|
||||
static async onSyncArtifact(_event, target) {
|
||||
const itemId = target.closest("[data-item-id]")?.dataset.itemId
|
||||
return this.document.syncArtifact(itemId)
|
||||
}
|
||||
|
||||
static async onResetDaily() {
|
||||
return this.document.resetDaily()
|
||||
}
|
||||
|
||||
static async onRollResonancePerDay() {
|
||||
return this.document.rollResonancePerDay()
|
||||
}
|
||||
|
||||
static async onQuickRest() {
|
||||
return this.document.quickRest()
|
||||
}
|
||||
|
||||
static async onFullRest() {
|
||||
return this.document.fullRest()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { SYSTEM } from "../../config/system.mjs"
|
||||
import { buildSharedSelectOptions, numericOptions } from "./select-options.mjs"
|
||||
import { enrichHTMLFields } from "./rich-text.mjs"
|
||||
|
||||
const { HandlebarsApplicationMixin } = foundry.applications.api
|
||||
|
||||
export default class MGNEItemSheet extends HandlebarsApplicationMixin(foundry.applications.sheets.ItemSheetV2) {
|
||||
static DEFAULT_OPTIONS = {
|
||||
classes: ["mgne", "item-sheet"],
|
||||
position: {
|
||||
width: 720,
|
||||
height: "auto",
|
||||
},
|
||||
window: {
|
||||
resizable: true,
|
||||
},
|
||||
form: {
|
||||
submitOnChange: true,
|
||||
},
|
||||
actions: {
|
||||
editImage: MGNEItemSheet.onEditImage,
|
||||
},
|
||||
}
|
||||
|
||||
async _prepareContext() {
|
||||
const selectOptions = buildSharedSelectOptions()
|
||||
const systemFields = this.document.system.schema.fields
|
||||
if (this.document.type === "armor") selectOptions.penalties = numericOptions(0, 6, this.document.system.penalty)
|
||||
if (this.document.type === "shield") selectOptions.penalties = numericOptions(0, 4, this.document.system.penalty)
|
||||
|
||||
return {
|
||||
fields: this.document.schema.fields,
|
||||
systemFields,
|
||||
item: this.document,
|
||||
system: this.document.system,
|
||||
source: this.document.toObject(),
|
||||
config: SYSTEM,
|
||||
enrichedFields: await enrichHTMLFields(this.document.system, systemFields),
|
||||
isEditable: this.isEditable,
|
||||
selectOptions,
|
||||
}
|
||||
}
|
||||
|
||||
static async onEditImage(_event, target) {
|
||||
const attr = target.dataset.edit
|
||||
const current = foundry.utils.getProperty(this.document, attr)
|
||||
const picker = new FilePicker({
|
||||
current,
|
||||
type: "image",
|
||||
callback: path => this.document.update({ [attr]: path }),
|
||||
top: this.position.top + 40,
|
||||
left: this.position.left + 10,
|
||||
})
|
||||
return picker.browse()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import MGNEActorSheet from "./base-actor-sheet.mjs"
|
||||
import { SYSTEM } from "../../config/system.mjs"
|
||||
import { buildCharacterSelectOptions } from "./select-options.mjs"
|
||||
|
||||
export default class MGNECharacterSheet extends MGNEActorSheet {
|
||||
static DEFAULT_OPTIONS = {
|
||||
classes: ["character"],
|
||||
position: {
|
||||
width: 1040,
|
||||
height: 760,
|
||||
},
|
||||
}
|
||||
|
||||
static PARTS = {
|
||||
main: { template: "systems/fvtt-machine-gods-noxian-expanse/templates/character-main.hbs" },
|
||||
tabs: { template: "systems/fvtt-machine-gods-noxian-expanse/templates/character-tabs.hbs" },
|
||||
overview: { template: "systems/fvtt-machine-gods-noxian-expanse/templates/character-overview.hbs" },
|
||||
daily: { template: "systems/fvtt-machine-gods-noxian-expanse/templates/character-daily.hbs" },
|
||||
equipment: { template: "systems/fvtt-machine-gods-noxian-expanse/templates/character-equipment.hbs" },
|
||||
features: { template: "systems/fvtt-machine-gods-noxian-expanse/templates/character-features.hbs" },
|
||||
notes: { template: "systems/fvtt-machine-gods-noxian-expanse/templates/character-notes.hbs" },
|
||||
}
|
||||
|
||||
tabGroups = { sheet: "overview" }
|
||||
|
||||
getTabs() {
|
||||
const tabs = {
|
||||
overview: { id: "overview", group: "sheet", label: game.i18n.localize("MGNE.Tabs.overview") },
|
||||
daily: { id: "daily", group: "sheet", label: game.i18n.localize("MGNE.Tabs.daily") },
|
||||
equipment: { id: "equipment", group: "sheet", label: game.i18n.localize("MGNE.Tabs.equipment") },
|
||||
features: { id: "features", group: "sheet", label: game.i18n.localize("MGNE.Tabs.features") },
|
||||
notes: { id: "notes", group: "sheet", label: game.i18n.localize("MGNE.Tabs.notes") },
|
||||
}
|
||||
for (const tab of Object.values(tabs)) {
|
||||
tab.active = this.tabGroups[tab.group] === tab.id
|
||||
tab.cssClass = tab.active ? "active" : ""
|
||||
}
|
||||
return tabs
|
||||
}
|
||||
|
||||
async _prepareContext() {
|
||||
const context = await super._prepareContext()
|
||||
context.tabs = this.getTabs()
|
||||
context.abilityList = SYSTEM.abilityOrder.map(id => ({
|
||||
id,
|
||||
...SYSTEM.abilities[id],
|
||||
value: context.source.system.abilities?.[id]?.value ?? 0,
|
||||
}))
|
||||
context.selectOptions = {
|
||||
...context.selectOptions,
|
||||
...buildCharacterSelectOptions(context.system),
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
async _preparePartContext(partId, context) {
|
||||
const doc = this.document
|
||||
switch (partId) {
|
||||
case "overview":
|
||||
context.tab = context.tabs.overview
|
||||
context.valueConditions = Object.entries(doc.system.conditions ?? {})
|
||||
.filter(([id]) => SYSTEM.conditions[id]?.hasValue)
|
||||
.map(([id, cond]) => ({
|
||||
id,
|
||||
label: SYSTEM.conditions[id].label,
|
||||
value: cond.value,
|
||||
options: context.selectOptions.conditionValues,
|
||||
}))
|
||||
context.flagConditions = Object.entries(doc.system.conditions ?? {})
|
||||
.filter(([id]) => !SYSTEM.conditions[id]?.hasValue)
|
||||
.map(([id, cond]) => ({
|
||||
id,
|
||||
label: SYSTEM.conditions[id].label,
|
||||
active: cond.active,
|
||||
}))
|
||||
break
|
||||
case "daily":
|
||||
context.tab = context.tabs.daily
|
||||
break
|
||||
case "equipment":
|
||||
context.tab = context.tabs.equipment
|
||||
context.weapons = doc.itemTypes.weapon
|
||||
context.armors = doc.itemTypes.armor
|
||||
context.shields = doc.itemTypes.shield
|
||||
context.equipmentItems = doc.itemTypes.equipment
|
||||
context.cores = doc.itemTypes["resonance-core"]
|
||||
context.artifacts = doc.itemTypes.artifact
|
||||
break
|
||||
case "features":
|
||||
context.tab = context.tabs.features
|
||||
context.features = doc.itemTypes.feature
|
||||
break
|
||||
case "notes":
|
||||
context.tab = context.tabs.notes
|
||||
break
|
||||
}
|
||||
return context
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import MGNEActorSheet from "./base-actor-sheet.mjs"
|
||||
import { SYSTEM } from "../../config/system.mjs"
|
||||
|
||||
export default class MGNECompanionSheet extends MGNEActorSheet {
|
||||
static DEFAULT_OPTIONS = {
|
||||
classes: ["companion"],
|
||||
position: {
|
||||
width: 820,
|
||||
height: 700,
|
||||
},
|
||||
}
|
||||
|
||||
static PARTS = {
|
||||
main: { template: "systems/fvtt-machine-gods-noxian-expanse/templates/companion-main.hbs" },
|
||||
}
|
||||
|
||||
async _prepareContext() {
|
||||
const context = await super._prepareContext()
|
||||
context.abilityList = SYSTEM.abilityOrder.map(id => ({
|
||||
id,
|
||||
...SYSTEM.abilities[id],
|
||||
value: context.source.system.abilities?.[id]?.value ?? 0,
|
||||
}))
|
||||
return context
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import MGNEActorSheet from "./base-actor-sheet.mjs"
|
||||
import { SYSTEM } from "../../config/system.mjs"
|
||||
|
||||
export default class MGNECreatureSheet extends MGNEActorSheet {
|
||||
static DEFAULT_OPTIONS = {
|
||||
classes: ["creature"],
|
||||
position: {
|
||||
width: 760,
|
||||
height: 640,
|
||||
},
|
||||
}
|
||||
|
||||
static PARTS = {
|
||||
main: { template: "systems/fvtt-machine-gods-noxian-expanse/templates/creature-main.hbs" },
|
||||
}
|
||||
|
||||
async _prepareContext() {
|
||||
const context = await super._prepareContext()
|
||||
context.abilityList = SYSTEM.abilityOrder.map(id => ({
|
||||
id,
|
||||
...SYSTEM.abilities[id],
|
||||
value: context.source.system.abilities?.[id]?.value ?? 0,
|
||||
}))
|
||||
return context
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import MGNEItemSheet from "./base-item-sheet.mjs"
|
||||
|
||||
export default class MGNEEquipmentSheet extends MGNEItemSheet {
|
||||
static PARTS = {
|
||||
main: { template: "systems/fvtt-machine-gods-noxian-expanse/templates/equipment.hbs" },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import MGNEItemSheet from "./base-item-sheet.mjs"
|
||||
|
||||
export default class MGNEFeatureSheet extends MGNEItemSheet {
|
||||
static PARTS = {
|
||||
main: { template: "systems/fvtt-machine-gods-noxian-expanse/templates/feature.hbs" },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import MGNEItemSheet from "./base-item-sheet.mjs"
|
||||
|
||||
export default class MGNEResonanceCoreSheet extends MGNEItemSheet {
|
||||
static PARTS = {
|
||||
main: { template: "systems/fvtt-machine-gods-noxian-expanse/templates/resonance-core.hbs" },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export async function enrichHTMLFields(data, schemaFields) {
|
||||
const enrichedFields = {}
|
||||
|
||||
for (const [key, field] of Object.entries(schemaFields ?? {})) {
|
||||
if (field instanceof foundry.data.fields.HTMLField) {
|
||||
enrichedFields[key] = await foundry.applications.ux.TextEditor.implementation.enrichHTML(data?.[key] ?? "", { async: true })
|
||||
continue
|
||||
}
|
||||
|
||||
if (field instanceof foundry.data.fields.SchemaField) {
|
||||
const nested = await enrichHTMLFields(data?.[key], field.fields)
|
||||
if (Object.keys(nested).length) enrichedFields[key] = nested
|
||||
}
|
||||
}
|
||||
|
||||
return enrichedFields
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { SYSTEM } from "../../config/system.mjs"
|
||||
|
||||
function normalizeMax(max, current) {
|
||||
return Math.max(max ?? 0, Number.isFinite(current) ? current : 0)
|
||||
}
|
||||
|
||||
export function numericOptions(min, max, current = null) {
|
||||
const resolvedMin = Math.min(min, Number.isFinite(current) ? current : min)
|
||||
const resolvedMax = normalizeMax(max, current)
|
||||
return Array.from({ length: Math.max(0, resolvedMax - resolvedMin) + 1 }, (_, index) => {
|
||||
const value = resolvedMin + index
|
||||
return { value, label: String(value) }
|
||||
})
|
||||
}
|
||||
|
||||
export function objectOptions(choices) {
|
||||
return Object.entries(choices).map(([value, label]) => ({ value, label }))
|
||||
}
|
||||
|
||||
export function dieMax(die) {
|
||||
if (typeof die !== "string" || !die.startsWith("d")) return 0
|
||||
const faces = Number.parseInt(die.slice(1), 10)
|
||||
return Number.isFinite(faces) ? faces : 0
|
||||
}
|
||||
|
||||
export function buildSharedSelectOptions() {
|
||||
return {
|
||||
abilityValues: numericOptions(-3, 6),
|
||||
conditionValues: numericOptions(0, 12),
|
||||
moraleValues: numericOptions(2, 12),
|
||||
armorPenalties: numericOptions(0, 6),
|
||||
shieldPenalties: numericOptions(0, 4),
|
||||
weaponCategories: objectOptions(SYSTEM.weaponCategories),
|
||||
usageDice: objectOptions(SYSTEM.usageDieChoices),
|
||||
armorDice: objectOptions(SYSTEM.armorDieChoices),
|
||||
omenDice: objectOptions(SYSTEM.omenDieChoices),
|
||||
resonanceList: objectOptions(SYSTEM.resonanceList),
|
||||
equipmentSubtypes: objectOptions(SYSTEM.equipmentSubtypes),
|
||||
artifactIds: objectOptions(SYSTEM.artifactChoices),
|
||||
featureIds: objectOptions(SYSTEM.featureChoices),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCharacterSelectOptions(system) {
|
||||
return {
|
||||
omenCurrent: numericOptions(0, dieMax(system.omens?.die), system.omens?.current),
|
||||
resonanceUsed: numericOptions(0, system.resonance?.max ?? 0, system.resonance?.used),
|
||||
artifactSyncUsed: numericOptions(0, system.syncLimit ?? 0, system.artifactSync?.used),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import MGNEItemSheet from "./base-item-sheet.mjs"
|
||||
|
||||
export default class MGNEShieldSheet extends MGNEItemSheet {
|
||||
static PARTS = {
|
||||
main: { template: "systems/fvtt-machine-gods-noxian-expanse/templates/shield.hbs" },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import MGNEItemSheet from "./base-item-sheet.mjs"
|
||||
|
||||
export default class MGNEWeaponSheet extends MGNEItemSheet {
|
||||
static PARTS = {
|
||||
main: { template: "systems/fvtt-machine-gods-noxian-expanse/templates/weapon.hbs" },
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
export const SYSTEM_ID = "fvtt-machine-gods-noxian-expanse"
|
||||
export const ASCII = `
|
||||
Machine Gods of the Noxian Expanse
|
||||
`
|
||||
|
||||
const dieChoiceLabels = values => Object.fromEntries(values.map(value => [value, value === "depleted" ? "Depleted" : value.toUpperCase()]))
|
||||
const keyedNameChoices = (items, key) => Object.fromEntries(items.map(item => [item[key], item.name]))
|
||||
const toIconSlug = name => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")
|
||||
const itemIcon = slug => `systems/${SYSTEM_ID}/assets/icons/${slug}.svg`
|
||||
const withItemIcons = items => items.map(item => ({ ...item, img: itemIcon(toIconSlug(item.name)) }))
|
||||
let localized = false
|
||||
|
||||
function localizeEntryLabels(entries, prefix) {
|
||||
for (const [key, entry] of Object.entries(entries)) {
|
||||
entry.label = game.i18n.localize(`${prefix}.${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
function localizeChoiceLabels(entries, prefix) {
|
||||
for (const key of Object.keys(entries)) {
|
||||
entries[key] = game.i18n.localize(`${prefix}.${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const SYSTEM = {
|
||||
id: SYSTEM_ID,
|
||||
actorTypes: {
|
||||
character: { id: "character", label: "Character" },
|
||||
creature: { id: "creature", label: "Creature" },
|
||||
companion: { id: "companion", label: "Companion" },
|
||||
},
|
||||
itemTypes: {
|
||||
weapon: { id: "weapon", label: "Weapon", icon: itemIcon("weapon") },
|
||||
armor: { id: "armor", label: "Armor", icon: itemIcon("armor") },
|
||||
shield: { id: "shield", label: "Shield", icon: itemIcon("shield") },
|
||||
equipment: { id: "equipment", label: "Equipment", icon: itemIcon("equipment") },
|
||||
"resonance-core": { id: "resonance-core", label: "Resonance Core", icon: itemIcon("resonance-core") },
|
||||
artifact: { id: "artifact", label: "Artifact", icon: itemIcon("artifact") },
|
||||
feature: { id: "feature", label: "Feature", icon: itemIcon("feature") },
|
||||
},
|
||||
abilities: {
|
||||
agility: { id: "agility", label: "Agility" },
|
||||
presence: { id: "presence", label: "Presence" },
|
||||
strength: { id: "strength", label: "Strength" },
|
||||
toughness: { id: "toughness", label: "Toughness" },
|
||||
},
|
||||
abilityOrder: ["agility", "presence", "strength", "toughness"],
|
||||
conditions: {
|
||||
bleeding: { id: "bleeding", label: "Bleeding", hasValue: true },
|
||||
blinded: { id: "blinded", label: "Blinded" },
|
||||
burning: { id: "burning", label: "Burning" },
|
||||
fatigued: { id: "fatigued", label: "Fatigued", hasValue: true },
|
||||
infected: { id: "infected", label: "Infected" },
|
||||
poisoned: { id: "poisoned", label: "Poisoned" },
|
||||
prone: { id: "prone", label: "Prone" },
|
||||
restrained: { id: "restrained", label: "Restrained" },
|
||||
starved: { id: "starved", label: "Starved" },
|
||||
stunned: { id: "stunned", label: "Stunned", hasValue: true },
|
||||
},
|
||||
usageDice: ["d12", "d10", "d8", "d6", "d4", "depleted"],
|
||||
usageDieChoices: dieChoiceLabels(["d12", "d10", "d8", "d6", "d4", "depleted"]),
|
||||
armorDice: ["d12", "d10", "d8", "d6", "d4", "d2", "0"],
|
||||
armorDieChoices: dieChoiceLabels(["d12", "d10", "d8", "d6", "d4", "d2", "0"]),
|
||||
omenDice: ["d2", "d4", "d6", "d8"],
|
||||
omenDieChoices: dieChoiceLabels(["d2", "d4", "d6", "d8"]),
|
||||
weaponCategories: {
|
||||
melee: "Melee",
|
||||
ranged: "Ranged",
|
||||
},
|
||||
resonanceList: {
|
||||
accelerate: "Accelerate",
|
||||
blast: "Blast",
|
||||
"breathe-water": "Breathe Water",
|
||||
cauterize: "Cauterize",
|
||||
"create-illusion": "Create Illusion",
|
||||
distract: "Distract",
|
||||
"eagle-eye": "Eagle Eye",
|
||||
"empower-weapon": "Empower Weapon",
|
||||
fireball: "Fireball",
|
||||
hover: "Hover",
|
||||
"influence-mind": "Influence Mind",
|
||||
"knit-flesh": "Knit Flesh",
|
||||
"light-construct": "Light Construct",
|
||||
mirage: "Mirage",
|
||||
"negate-injury": "Negate Injury",
|
||||
paralyze: "Paralyze",
|
||||
shield: "Shield",
|
||||
shock: "Shock",
|
||||
shroud: "Shroud",
|
||||
"summon-mist": "Summon Mist",
|
||||
},
|
||||
starterWeapons: withItemIcons([
|
||||
{ name: "Club", damage: "1d4", category: "melee", range: "Touch" },
|
||||
{ name: "Dagger", damage: "1d4", category: "melee", range: "Touch" },
|
||||
{ name: "Handaxe", damage: "1d6", category: "melee", range: "Touch" },
|
||||
{ name: "Quarterstaff", damage: "1d4", category: "melee", range: "Touch" },
|
||||
{ name: "Whip", damage: "1d4", category: "melee", range: "Near" },
|
||||
{ name: "Shortbow", damage: "1d6", category: "ranged", range: "Near/Far" },
|
||||
{ name: "Spear", damage: "1d6", category: "melee", range: "Near" },
|
||||
{ name: "Longsword", damage: "1d8", category: "melee", range: "Touch" },
|
||||
{ name: "Heavy Crossbow", damage: "1d10", category: "ranged", range: "Far" },
|
||||
{ name: "Rapier", damage: "1d6", category: "melee", range: "Touch" },
|
||||
{ name: "Halberd", damage: "1d10", category: "melee", range: "Near" },
|
||||
{ name: "Maul", damage: "1d10", category: "melee", range: "Touch" },
|
||||
]),
|
||||
starterArmor: withItemIcons([
|
||||
{ name: "Clothing (Average)", type: "armor", armorDie: "d2", penalty: 0 },
|
||||
{ name: "Helm", type: "armor", armorDie: "d2", penalty: 0 },
|
||||
{ name: "Medium Shield", type: "shield", armorDie: "d4", penalty: 0 },
|
||||
{ name: "Gambeson", type: "armor", armorDie: "d4", penalty: 0 },
|
||||
{ name: "Padded Leather", type: "armor", armorDie: "d4", penalty: 1 },
|
||||
{ name: "Chain Shirt", type: "armor", armorDie: "d6", penalty: 1 },
|
||||
{ name: "Half Plate", type: "armor", armorDie: "d8", penalty: 2 },
|
||||
{ name: "Full Plate", type: "armor", armorDie: "d10", penalty: 2 },
|
||||
]),
|
||||
starterEquipment: withItemIcons([
|
||||
{ name: "Rations", type: "equipment", subtype: "consumable", quantity: 1, usageDie: "d8", consumable: true },
|
||||
{ name: "Waterskin", type: "equipment", subtype: "travel", quantity: 1, usageDie: "d8", consumable: true },
|
||||
{ name: "Sack", type: "equipment", subtype: "container", quantity: 1, usageDie: "depleted", consumable: false },
|
||||
{ name: "Backpack", type: "equipment", subtype: "container", quantity: 1, usageDie: "depleted", consumable: false },
|
||||
{ name: "Rope", type: "equipment", subtype: "tool", quantity: 1, usageDie: "depleted", consumable: false },
|
||||
{ name: "Medical Supplies", type: "equipment", subtype: "tool", quantity: 1, usageDie: "d6", consumable: true },
|
||||
]),
|
||||
equipmentSubtypes: {
|
||||
gear: "Gear",
|
||||
consumable: "Consumable",
|
||||
travel: "Travel",
|
||||
container: "Container",
|
||||
tool: "Tool",
|
||||
},
|
||||
starterCores: withItemIcons([
|
||||
{ name: "Accelerate Core", type: "resonance-core", resonationId: "accelerate", usageDie: "d6" },
|
||||
{ name: "Blast Core", type: "resonance-core", resonationId: "blast", usageDie: "d6" },
|
||||
{ name: "Cauterize Core", type: "resonance-core", resonationId: "cauterize", usageDie: "d6" },
|
||||
{ name: "Empower Weapon Core", type: "resonance-core", resonationId: "empower-weapon", usageDie: "d6" },
|
||||
]),
|
||||
sampleArtifacts: withItemIcons([
|
||||
{ name: "Shiver Lens", type: "artifact", artifactId: "shiver-lens", usageDie: "d6", description: "A cracked lens that reveals hidden heat signatures." },
|
||||
{ name: "Processional Halo", type: "artifact", artifactId: "processional-halo", usageDie: "d6", description: "A floating crown of light that amplifies solemn commands." },
|
||||
{ name: "Null Forge Spike", type: "artifact", artifactId: "null-forge-spike", usageDie: "d4", description: "A ritual spike used to jam malfunctioning relics into silence." },
|
||||
{ name: "Lux Relay Idol", type: "artifact", artifactId: "lux-relay-idol", usageDie: "d6", description: "A palm-sized relay that stores and redirects ambient luminance." },
|
||||
]),
|
||||
artifactChoices: keyedNameChoices([
|
||||
{ name: "Shiver Lens", artifactId: "shiver-lens" },
|
||||
{ name: "Processional Halo", artifactId: "processional-halo" },
|
||||
{ name: "Null Forge Spike", artifactId: "null-forge-spike" },
|
||||
{ name: "Lux Relay Idol", artifactId: "lux-relay-idol" },
|
||||
], "artifactId"),
|
||||
sampleFeatures: withItemIcons([
|
||||
{ name: "Akimbo Hit Priest", type: "feature", featureId: "akimbo-hit-priest", description: "Once per round after hitting with an attack, suffer Fatigued (1) to attack again." },
|
||||
{ name: "Ambivalent Slouch", type: "feature", featureId: "ambivalent-slouch", description: "Gain +1 carrying capacity and reduce armor or shield Agility penalties by 1." },
|
||||
{ name: "Avaricious Gubbingrifter", type: "feature", featureId: "avaricious-gubbingrifter", description: "Gain three random resonance cores and -1 DR to invoking resonations." },
|
||||
{ name: "Backbiter", type: "feature", featureId: "backbiter", description: "When you win initiative, first-round attacks bypass 2d6 armor." },
|
||||
{ name: "Chart Reading Maniac", type: "feature", featureId: "chart-reading-maniac", description: "Gain an extra exploration turn per day and reroll one exploration or ruin table per day." },
|
||||
{ name: "Liturgicantal Blesswell", type: "feature", featureId: "liturgicantal-blesswell", description: "Restore d4 HP d4 times per full rest and repair one usage die by a step once per day." },
|
||||
]),
|
||||
featureChoices: keyedNameChoices([
|
||||
{ name: "Akimbo Hit Priest", featureId: "akimbo-hit-priest" },
|
||||
{ name: "Ambivalent Slouch", featureId: "ambivalent-slouch" },
|
||||
{ name: "Avaricious Gubbingrifter", featureId: "avaricious-gubbingrifter" },
|
||||
{ name: "Backbiter", featureId: "backbiter" },
|
||||
{ name: "Chart Reading Maniac", featureId: "chart-reading-maniac" },
|
||||
{ name: "Liturgicantal Blesswell", featureId: "liturgicantal-blesswell" },
|
||||
], "featureId"),
|
||||
companions: {
|
||||
noble: {
|
||||
name: "Beguiled Noble",
|
||||
hp: 6,
|
||||
morale: 9,
|
||||
armorDie: "d4",
|
||||
attackLabel: "Blade",
|
||||
attackDamage: "1d6",
|
||||
},
|
||||
mercenary: {
|
||||
name: "Dustland Mercenary",
|
||||
hp: 8,
|
||||
morale: 7,
|
||||
armorDie: "d4",
|
||||
attackLabel: "Cleaver",
|
||||
attackDamage: "1d4+2",
|
||||
},
|
||||
pickpocket: {
|
||||
name: "Scrapling Pickpocket",
|
||||
hp: 3,
|
||||
morale: 4,
|
||||
armorDie: "0",
|
||||
attackLabel: "Folding Knife",
|
||||
attackDamage: "1d2",
|
||||
},
|
||||
cantor: {
|
||||
name: "Silicon Cantor",
|
||||
hp: 4,
|
||||
morale: 6,
|
||||
armorDie: "0",
|
||||
attackLabel: "Book Thump",
|
||||
attackDamage: "1",
|
||||
},
|
||||
},
|
||||
tables: {
|
||||
eucatastrophe: [
|
||||
"Recover d4 HP.",
|
||||
"Invoke the resonation again immediately.",
|
||||
"Synchronize one additional artifact for the day.",
|
||||
"Gain -1 DR to invoking resonations for five minutes.",
|
||||
"Increase daily resonation uses by 2 for the day.",
|
||||
"Gain +1 carrying capacity for the day.",
|
||||
"Gain -4 DR to your next check within five minutes.",
|
||||
"Gain +1 max HP for the day.",
|
||||
"Recover one omen.",
|
||||
"Borrow a random nearby feature or increase a random ability by 1 until end of day."
|
||||
],
|
||||
catastrophe: [
|
||||
"You are reduced to 0 HP.",
|
||||
"The resonation targets the wrong creature.",
|
||||
"A random nearby artifact explodes.",
|
||||
"The resonation is cast twice at random targets.",
|
||||
"You become Restrained for five minutes.",
|
||||
"You are knocked Prone and suffer Stunned (2).",
|
||||
"Your worn armor is consumed.",
|
||||
"Suffer Fatigued (1), doubling if repeated the same day.",
|
||||
"Discard one omen.",
|
||||
"All artifacts desynchronize and you cannot invoke resonations for the day."
|
||||
],
|
||||
triumphs: [
|
||||
"Discover a useful resource.",
|
||||
"Gain -3 DR to your next check within five minutes.",
|
||||
"Discard a condition or recover d2 HP.",
|
||||
"Grant a nearby ally -2 DR on their next check.",
|
||||
"Ignore all penalties on your next check.",
|
||||
"Discard Prone and Restrained, then move immediately.",
|
||||
"Restore a weapon or armor usage die by one step.",
|
||||
"Take an immediate action or find 3d10 coins.",
|
||||
"Take an immediate action or gain -2 DR to your next check.",
|
||||
"Move one point from one ability to another for one hour."
|
||||
],
|
||||
mishaps: [
|
||||
"Downgrade involved equipment usage dice or suffer d4 damage bypassing armor.",
|
||||
"A hostile random creature investigates.",
|
||||
"Start an uncontrolled fire or suffer d4 damage bypassing armor.",
|
||||
"Suffer Infected.",
|
||||
"Suffer +1 DR on checks until you fully rest.",
|
||||
"You cannot invoke resonations for d8 hours.",
|
||||
"Suffer +3 DR on your next check this hour.",
|
||||
"Suffer d6 damage bypassing armor, minimum 1 HP remaining.",
|
||||
"Suffer Bleeding (1) in combat or d4 damage bypassing armor outside combat.",
|
||||
"Roll twice on the mishaps table and take both results."
|
||||
],
|
||||
breakResults: {
|
||||
1: "Unconscious for d4 rounds, then wake with d4 HP.",
|
||||
2: "As above, and a random working limb becomes useless until treated.",
|
||||
3: "As above, and without emergency treatment you die in d2 hours.",
|
||||
4: "Immediate death."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function localizeSystemConfig() {
|
||||
if (localized) return
|
||||
localized = true
|
||||
|
||||
localizeEntryLabels(SYSTEM.actorTypes, "MGNE.ActorTypes")
|
||||
localizeEntryLabels(SYSTEM.itemTypes, "MGNE.ItemTypes")
|
||||
localizeEntryLabels(SYSTEM.abilities, "MGNE.Abilities")
|
||||
localizeEntryLabels(SYSTEM.conditions, "MGNE.Conditions")
|
||||
localizeChoiceLabels(SYSTEM.weaponCategories, "MGNE.WeaponCategories")
|
||||
localizeChoiceLabels(SYSTEM.resonanceList, "MGNE.Resonations")
|
||||
localizeChoiceLabels(SYSTEM.equipmentSubtypes, "MGNE.EquipmentSubtypes")
|
||||
SYSTEM.usageDieChoices.depleted = game.i18n.localize("MGNE.Common.Depleted")
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as MGNEActor } from "./actor.mjs"
|
||||
export { default as MGNECombat } from "./combat.mjs"
|
||||
export { default as MGNEItem } from "./item.mjs"
|
||||
export { default as MGNERoll } from "./roll.mjs"
|
||||
@@ -0,0 +1,386 @@
|
||||
import MGNERoll from "./roll.mjs"
|
||||
import { SYSTEM, SYSTEM_ID } from "../config/system.mjs"
|
||||
|
||||
const t = key => game.i18n.localize(key)
|
||||
const f = (key, data = {}) => game.i18n.format(key, data)
|
||||
const PENDING_DAMAGE_FLAG = "pendingDamageBonus"
|
||||
const PENDING_DEFENSE_FLAG = "pendingDefenseFumble"
|
||||
|
||||
function formatActionLabel(baseLabel, actionLabel) {
|
||||
const trimmedBase = `${baseLabel ?? ""}`.trim()
|
||||
if (!trimmedBase) return actionLabel
|
||||
return trimmedBase.toLowerCase().endsWith(actionLabel.toLowerCase()) ? trimmedBase : `${trimmedBase} ${actionLabel}`
|
||||
}
|
||||
|
||||
function normalizeGenericActionLabel(baseLabel, genericLabel) {
|
||||
const trimmedBase = `${baseLabel ?? ""}`.trim()
|
||||
if (!trimmedBase) return genericLabel
|
||||
|
||||
const normalizedWords = trimmedBase
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map(word => word.toLowerCase())
|
||||
|
||||
return normalizedWords.every(word => word === genericLabel.toLowerCase()) ? genericLabel : trimmedBase
|
||||
}
|
||||
|
||||
function maxDieByRank(items, property) {
|
||||
const rank = { d12: 0, d10: 1, d8: 2, d6: 3, d4: 4, d2: 5, 0: 6, depleted: 7 }
|
||||
return [...items].sort((left, right) => (rank[left.system[property]] ?? 99) - (rank[right.system[property]] ?? 99))[0] ?? null
|
||||
}
|
||||
|
||||
export default class MGNEActor extends Actor {
|
||||
async setPendingDamageBonus(contextId, multiplier = 2) {
|
||||
await this.setFlag(SYSTEM_ID, PENDING_DAMAGE_FLAG, { contextId, multiplier })
|
||||
}
|
||||
|
||||
async consumePendingDamageBonus(contextId) {
|
||||
const bonus = this.getFlag(SYSTEM_ID, PENDING_DAMAGE_FLAG)
|
||||
if (!bonus || bonus.contextId !== contextId) return null
|
||||
await this.unsetFlag(SYSTEM_ID, PENDING_DAMAGE_FLAG)
|
||||
return bonus
|
||||
}
|
||||
|
||||
async setPendingDefenseFumble() {
|
||||
await this.setFlag(SYSTEM_ID, PENDING_DEFENSE_FLAG, { active: true })
|
||||
}
|
||||
|
||||
async consumePendingDefenseFumble() {
|
||||
const pending = this.getFlag(SYSTEM_ID, PENDING_DEFENSE_FLAG)
|
||||
if (!pending?.active) return null
|
||||
await this.unsetFlag(SYSTEM_ID, PENDING_DEFENSE_FLAG)
|
||||
return pending
|
||||
}
|
||||
|
||||
getEquippedArmorItems() {
|
||||
return this.items.filter(item => ["armor", "shield"].includes(item.type) && item.system.equipped && !item.system.broken && item.system.armorDie !== "0")
|
||||
}
|
||||
|
||||
getArmorRollFormula() {
|
||||
const dice = this.getEquippedArmorItems().map(item => `1${item.system.armorDie}`)
|
||||
return dice.length ? dice.join(" + ") : "0"
|
||||
}
|
||||
|
||||
async degradeArmorStep(steps = 1) {
|
||||
const item = maxDieByRank(this.getEquippedArmorItems(), "armorDie")
|
||||
if (!item) return null
|
||||
|
||||
const nextDie = MGNERoll.stepDownDie(item.system.armorDie, steps, SYSTEM.armorDice)
|
||||
const updates = { "system.armorDie": nextDie }
|
||||
if (nextDie === "0") {
|
||||
updates["system.broken"] = true
|
||||
updates["system.equipped"] = false
|
||||
}
|
||||
|
||||
await item.update(updates)
|
||||
return { item, nextDie }
|
||||
}
|
||||
|
||||
async rollAbility(abilityId, options = {}) {
|
||||
return MGNERoll.promptCheck({
|
||||
actor: this,
|
||||
abilityId,
|
||||
label: options.label ?? f("MGNE.Roll.CheckLabel", { ability: SYSTEM.abilities[abilityId]?.label ?? abilityId }),
|
||||
baseDR: options.baseDR ?? 12,
|
||||
rollType: options.rollType ?? "check",
|
||||
item: options.item ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
async rollDefense() {
|
||||
const result = await this.rollAbility("agility", { label: t("MGNE.Roll.DefenseCheck"), rollType: "defense" })
|
||||
if (result?.fumble) await this.setPendingDefenseFumble()
|
||||
return result
|
||||
}
|
||||
|
||||
async rollWeapon(itemId) {
|
||||
const item = this.items.get(itemId)
|
||||
if (!item) return null
|
||||
if (item.system.broken) {
|
||||
ui.notifications.warn(f("MGNE.Notification.ItemBroken", { item: item.name }))
|
||||
return null
|
||||
}
|
||||
|
||||
const abilityId = item.system.category === "ranged" ? "presence" : "strength"
|
||||
const result = await this.rollAbility(abilityId, {
|
||||
label: f("MGNE.Roll.ItemAttackLabel", { item: item.name }),
|
||||
rollType: "attack",
|
||||
item,
|
||||
})
|
||||
|
||||
if (!result) return null
|
||||
if (result.fumble) await item.update({ "system.broken": true })
|
||||
if (result.critical) {
|
||||
await this.setPendingDamageBonus(item.id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async rollDamage(itemId) {
|
||||
const item = this.items.get(itemId)
|
||||
if (!item) return null
|
||||
return MGNERoll.rollDamage({
|
||||
actor: this,
|
||||
item,
|
||||
targetActor: MGNERoll.getFirstTargetActor(),
|
||||
})
|
||||
}
|
||||
|
||||
async rollProfileAttack() {
|
||||
const attackBaseLabel = normalizeGenericActionLabel(this.system.attack?.label ?? t("MGNE.Common.Attack"), t("MGNE.Common.Attack"))
|
||||
const attackLabel = formatActionLabel(attackBaseLabel, t("MGNE.Common.Attack"))
|
||||
const result = await this.rollAbility("strength", {
|
||||
label: attackLabel,
|
||||
rollType: "attack",
|
||||
})
|
||||
|
||||
if (result?.critical) {
|
||||
await this.setPendingDamageBonus("profile-attack")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async rollProfileDamage() {
|
||||
const attackBaseLabel = normalizeGenericActionLabel(this.system.attack?.label ?? t("MGNE.Common.Attack"), t("MGNE.Common.Attack"))
|
||||
const damageLabel = attackBaseLabel === t("MGNE.Common.Attack")
|
||||
? t("MGNE.Common.Damage")
|
||||
: formatActionLabel(attackBaseLabel, t("MGNE.Common.Damage"))
|
||||
return MGNERoll.rollFlatDamage({
|
||||
actor: this,
|
||||
label: damageLabel,
|
||||
formula: this.system.attack?.damage ?? "1",
|
||||
targetActor: MGNERoll.getFirstTargetActor(),
|
||||
})
|
||||
}
|
||||
|
||||
async rollResonancePerDay({ resetUsed = true, apply = true } = {}) {
|
||||
const resonanceRoll = await (new Roll("1d4")).evaluate()
|
||||
const presence = this.system.abilities?.presence?.value ?? 0
|
||||
const max = Math.max(1, presence + resonanceRoll.total)
|
||||
const updates = { "system.resonance.max": max }
|
||||
if (resetUsed) updates["system.resonance.used"] = 0
|
||||
if (apply) await this.update(updates)
|
||||
return { resonanceRoll, max }
|
||||
}
|
||||
|
||||
async rollResonation(itemId) {
|
||||
const item = this.items.get(itemId)
|
||||
if (!item || item.type !== "resonance-core") return null
|
||||
if (item.system.burnedOut || item.system.usageDie === "depleted") {
|
||||
ui.notifications.warn(f("MGNE.Notification.ItemBurnedOut", { item: item.name }))
|
||||
return null
|
||||
}
|
||||
if ((this.system.resonance?.used ?? 0) >= (this.system.resonance?.max ?? 0)) {
|
||||
ui.notifications.warn(f("MGNE.Notification.ResonancePerDayReached", { actor: this.name }))
|
||||
return null
|
||||
}
|
||||
|
||||
const result = await this.rollAbility("presence", {
|
||||
label: f("MGNE.Roll.InvocationLabel", { item: item.name }),
|
||||
rollType: "resonance",
|
||||
item,
|
||||
})
|
||||
|
||||
if (!result) return null
|
||||
|
||||
await this.update({ "system.resonance.used": (this.system.resonance.used ?? 0) + 1 })
|
||||
|
||||
if (!result.success) {
|
||||
await this.applyDamage(1, { sourceItem: item, ignoreArmor: true, chat: false })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async rollMorale() {
|
||||
return MGNERoll.rollMorale(this)
|
||||
}
|
||||
|
||||
async toggleItemEquipped(itemId) {
|
||||
const item = this.items.get(itemId)
|
||||
if (!item) return null
|
||||
if (item.system.broken) {
|
||||
ui.notifications.warn(f("MGNE.Notification.ItemBroken", { item: item.name }))
|
||||
return null
|
||||
}
|
||||
return item.update({ "system.equipped": !item.system.equipped })
|
||||
}
|
||||
|
||||
async rollUsage(itemId) {
|
||||
const item = this.items.get(itemId)
|
||||
if (!item) return null
|
||||
return item.rollUsage()
|
||||
}
|
||||
|
||||
async quickRest() {
|
||||
const roll = await (new Roll("1d4")).evaluate()
|
||||
const hp = this.system.hp?.value ?? 0
|
||||
const hpMax = this.system.hp?.max ?? hp
|
||||
const healed = Math.max(0, Math.min(hpMax, hp + roll.total) - hp)
|
||||
const newHp = Math.min(hpMax, hp + roll.total)
|
||||
|
||||
await this.update({ "system.hp.value": newHp })
|
||||
await MGNERoll.createRestCard({
|
||||
actor: this,
|
||||
label: f("MGNE.Roll.QuickRestLabel", { actor: this.name }),
|
||||
subtitle: t("MGNE.Character.QuickRestHelp"),
|
||||
roll,
|
||||
outcome: f("MGNE.Roll.RestoredHP", { amount: healed }),
|
||||
specialText: f("MGNE.Roll.HPNowMax", { hp: newHp, max: hpMax }),
|
||||
})
|
||||
|
||||
return { roll, healed, newHp }
|
||||
}
|
||||
|
||||
async fullRest() {
|
||||
const hp = this.system.hp?.value ?? 0
|
||||
const hpMax = this.system.hp?.max ?? hp
|
||||
const healRoll = await (new Roll("1d6")).evaluate()
|
||||
const omenDie = this.system.omens?.die ?? "d2"
|
||||
const omenRoll = await (new Roll(`1${omenDie}`)).evaluate()
|
||||
const healed = Math.max(0, Math.min(hpMax, hp + healRoll.total) - hp)
|
||||
const newHp = Math.min(hpMax, hp + healRoll.total)
|
||||
|
||||
await this.update({
|
||||
"system.hp.value": newHp,
|
||||
"system.omens.current": omenRoll.total,
|
||||
})
|
||||
|
||||
await MGNERoll.createRestCard({
|
||||
actor: this,
|
||||
label: f("MGNE.Roll.FullRestLabel", { actor: this.name }),
|
||||
subtitle: t("MGNE.Character.FullRestHelp"),
|
||||
roll: healRoll,
|
||||
outcome: f("MGNE.Roll.RestoredHP", { amount: healed }),
|
||||
specialText: `${f("MGNE.Roll.HPNowMax", { hp: newHp, max: hpMax })} ${f("MGNE.Roll.OmensReset", {
|
||||
omens: omenRoll.total,
|
||||
die: omenDie.toUpperCase(),
|
||||
roll: omenRoll.total,
|
||||
})}`,
|
||||
})
|
||||
|
||||
return { healRoll, omenRoll, healed, newHp, omens: omenRoll.total }
|
||||
}
|
||||
|
||||
async syncArtifact(itemId) {
|
||||
const item = this.items.get(itemId)
|
||||
if (!item || item.type !== "artifact") return null
|
||||
|
||||
if (item.system.synchronized) {
|
||||
return item.update({
|
||||
"system.synchronized": false,
|
||||
"system.synchronizedTo": "",
|
||||
})
|
||||
}
|
||||
|
||||
const syncLimit = Math.max(0, this.system.syncLimit ?? this.system.abilities?.toughness?.value ?? 0)
|
||||
const used = this.system.artifactSync?.used ?? 0
|
||||
if (used >= syncLimit) {
|
||||
ui.notifications.warn(f("MGNE.Notification.CannotSyncMore", { actor: this.name }))
|
||||
return null
|
||||
}
|
||||
|
||||
await this.update({ "system.artifactSync.used": used + 1 })
|
||||
return item.update({
|
||||
"system.synchronized": true,
|
||||
"system.synchronizedTo": this.id,
|
||||
})
|
||||
}
|
||||
|
||||
async resetDaily() {
|
||||
const omenDie = this.system.omens?.die ?? "d2"
|
||||
const omenRoll = await (new Roll(`1${omenDie}`)).evaluate()
|
||||
const { max: resonanceMax } = await this.rollResonancePerDay({ resetUsed: false, apply: false })
|
||||
|
||||
await this.update({
|
||||
"system.omens.current": omenRoll.total,
|
||||
"system.resonance.max": resonanceMax,
|
||||
"system.resonance.used": 0,
|
||||
"system.artifactSync.used": 0,
|
||||
"system.survival.salvationUsed": false,
|
||||
})
|
||||
|
||||
const syncedArtifacts = this.items.filter(item => item.type === "artifact" && item.system.synchronized)
|
||||
for (const artifact of syncedArtifacts) {
|
||||
await artifact.update({
|
||||
"system.synchronized": false,
|
||||
"system.synchronizedTo": "",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async applyDamage(amount, { sourceActor = null, sourceItem = null, ignoreArmor = false, critical = false, chat = true } = {}) {
|
||||
const defenseFumble = await this.consumePendingDefenseFumble()
|
||||
const incomingDamage = defenseFumble ? amount * 2 : amount
|
||||
const hp = this.system.hp?.value ?? 0
|
||||
let armorRoll = null
|
||||
let absorbed = 0
|
||||
if (!ignoreArmor) {
|
||||
const formula = this.getArmorRollFormula()
|
||||
if (formula !== "0") {
|
||||
armorRoll = await (new Roll(formula)).evaluate()
|
||||
absorbed = armorRoll.total
|
||||
}
|
||||
}
|
||||
|
||||
let appliedDamage = Math.max(0, incomingDamage - absorbed)
|
||||
let nextHp = hp - appliedDamage
|
||||
let breakText = ""
|
||||
let defenseFumbleText = ""
|
||||
let criticalArmorText = ""
|
||||
|
||||
if (this.type === "character" && hp > 0 && nextHp < 0 && !(this.system.survival?.salvationUsed)) {
|
||||
nextHp = 0
|
||||
appliedDamage = hp
|
||||
await this.update({ "system.survival.salvationUsed": true })
|
||||
}
|
||||
|
||||
if (nextHp <= 0 && hp > 0) {
|
||||
const breakRoll = await (new Roll("1d4")).evaluate()
|
||||
breakText = SYSTEM.tables.breakResults[breakRoll.total] ?? ""
|
||||
}
|
||||
|
||||
await this.update({ "system.hp.value": nextHp })
|
||||
|
||||
if (defenseFumble) {
|
||||
const degraded = await this.degradeArmorStep()
|
||||
defenseFumbleText = degraded
|
||||
? f("MGNE.Roll.DefenseFumbleApplied", { item: degraded.item.name, die: degraded.nextDie.toUpperCase() })
|
||||
: t("MGNE.Roll.DefenseFumbleNoArmor")
|
||||
}
|
||||
|
||||
if (critical) {
|
||||
const degraded = await this.degradeArmorStep()
|
||||
criticalArmorText = degraded
|
||||
? f("MGNE.Roll.ArmorDegradedCritical", { item: degraded.item.name, die: degraded.nextDie.toUpperCase() })
|
||||
: t("MGNE.Roll.ArmorNothingToDegrade")
|
||||
}
|
||||
|
||||
if (chat) {
|
||||
await MGNERoll.applyDamageCard({
|
||||
actor: this,
|
||||
sourceActor,
|
||||
sourceItem,
|
||||
amount: incomingDamage,
|
||||
armorRoll,
|
||||
appliedDamage,
|
||||
newHp: nextHp,
|
||||
breakText,
|
||||
defenseFumbleText,
|
||||
criticalArmorText,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
amount: incomingDamage,
|
||||
absorbed,
|
||||
appliedDamage,
|
||||
newHp: nextHp,
|
||||
breakText,
|
||||
defenseFumbleText,
|
||||
criticalArmorText,
|
||||
summary: f("MGNE.Chat.DamageSummary", { absorbed, appliedDamage, hp: nextHp }),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import MGNERoll from "./roll.mjs"
|
||||
import { SYSTEM_ID } from "../config/system.mjs"
|
||||
|
||||
const t = key => game.i18n.localize(key)
|
||||
const f = (key, data = {}) => game.i18n.format(key, data)
|
||||
const INITIATIVE_FLAG = "initiativeState"
|
||||
const SIDE_BONUS = 20
|
||||
const FLEE_BASE_TARGET = 5
|
||||
const FLEE_MIN_TARGET = 2
|
||||
|
||||
function isPlayerSide(combatant) {
|
||||
return Boolean(combatant?.actor?.hasPlayerOwner)
|
||||
}
|
||||
|
||||
function fleeDialogContent() {
|
||||
const options = Array.from({ length: FLEE_BASE_TARGET - FLEE_MIN_TARGET + 1 }, (_, index) => {
|
||||
const value = index
|
||||
return `<option value="${value}">${value}</option>`
|
||||
}).join("")
|
||||
|
||||
return `
|
||||
<form class="mgne-flee-dialog">
|
||||
<div class="form-group">
|
||||
<label>${t("MGNE.Combat.StudyActions")}</label>
|
||||
<select name="studyActions">${options}</select>
|
||||
<p class="notes">${t("MGNE.Combat.StudyHelp")}</p>
|
||||
</div>
|
||||
</form>
|
||||
`
|
||||
}
|
||||
|
||||
export default class MGNECombat extends Combat {
|
||||
getFleeCombatant(combatantId = null) {
|
||||
if (combatantId) return this.combatants.get(combatantId) ?? null
|
||||
|
||||
const controlledCombatant = canvas.tokens?.controlled?.[0]?.combatant ?? null
|
||||
return this.combatant ?? controlledCombatant
|
||||
}
|
||||
|
||||
async getInitiativeState() {
|
||||
const existing = this.getFlag(SYSTEM_ID, INITIATIVE_FLAG)
|
||||
if (existing && Number.isFinite(existing.sideRoll)) return existing
|
||||
|
||||
const sideRoll = await (new Roll("1d6")).evaluate()
|
||||
const initiativeState = {
|
||||
sideRoll: sideRoll.total,
|
||||
playersActFirst: sideRoll.total >= 4,
|
||||
}
|
||||
|
||||
await this.setFlag(SYSTEM_ID, INITIATIVE_FLAG, initiativeState)
|
||||
await ChatMessage.create({
|
||||
speaker: ChatMessage.getSpeaker(),
|
||||
content: `<p>${f("MGNE.Initiative.SideRoll", { roll: initiativeState.sideRoll })}</p><p>${t(initiativeState.playersActFirst ? "MGNE.Initiative.PlayersFirst" : "MGNE.Initiative.EnemiesFirst")}</p><p>${t("MGNE.Initiative.TieBreak")}</p>`,
|
||||
})
|
||||
return initiativeState
|
||||
}
|
||||
|
||||
async rollInitiative(ids, { updateTurn = true } = {}) {
|
||||
const combatantIds = typeof ids === "string" ? [ids] : Array.from(ids ?? [])
|
||||
if (!combatantIds.length) return this
|
||||
|
||||
const currentCombatantId = this.combatant?.id ?? null
|
||||
const initiativeState = await this.getInitiativeState()
|
||||
const updates = []
|
||||
|
||||
for (const id of combatantIds) {
|
||||
const combatant = this.combatants.get(id)
|
||||
if (!combatant) continue
|
||||
|
||||
const agility = combatant.actor?.system?.abilities?.agility?.value ?? 0
|
||||
const roll = await (new Roll("1d6 + @agility", { agility })).evaluate()
|
||||
const actsWithPriority = isPlayerSide(combatant) === initiativeState.playersActFirst
|
||||
updates.push({
|
||||
_id: id,
|
||||
initiative: roll.total + (actsWithPriority ? SIDE_BONUS : 0),
|
||||
})
|
||||
}
|
||||
|
||||
if (updates.length) await this.updateEmbeddedDocuments("Combatant", updates)
|
||||
|
||||
if (updateTurn && currentCombatantId) {
|
||||
const turn = this.turns.findIndex(combatant => combatant.id === currentCombatantId)
|
||||
if (turn >= 0) await this.update({ turn })
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
async rollFlee({ combatantId = null } = {}) {
|
||||
const combatant = this.getFleeCombatant(combatantId)
|
||||
if (!combatant?.actor) {
|
||||
ui.notifications.warn(t("MGNE.Combat.FleeNoCombatant"))
|
||||
return null
|
||||
}
|
||||
|
||||
const dialogData = await foundry.applications.api.DialogV2.wait({
|
||||
window: { title: t("MGNE.Combat.Flee") },
|
||||
classes: ["mgne", "roll-dialog"],
|
||||
content: fleeDialogContent(),
|
||||
buttons: [{
|
||||
label: t("MGNE.Combat.Flee"),
|
||||
icon: "fa-solid fa-person-running",
|
||||
callback: (_event, button) => {
|
||||
const value = Number.parseInt(button.form?.elements.studyActions?.value ?? "0", 10)
|
||||
return { studyActions: Math.max(0, Math.min(FLEE_BASE_TARGET - FLEE_MIN_TARGET, value || 0)) }
|
||||
},
|
||||
}],
|
||||
rejectClose: false,
|
||||
})
|
||||
|
||||
if (!dialogData) return null
|
||||
|
||||
const studyActions = dialogData.studyActions ?? 0
|
||||
const target = Math.max(FLEE_MIN_TARGET, FLEE_BASE_TARGET - studyActions)
|
||||
const roll = await (new Roll("1d6")).evaluate()
|
||||
const escaped = roll.total >= target
|
||||
|
||||
if (escaped) {
|
||||
await combatant.delete()
|
||||
} else {
|
||||
await combatant.actor.update({ "system.hp.value": 0 })
|
||||
await combatant.update({ defeated: true })
|
||||
}
|
||||
|
||||
await MGNERoll.createActionCard({
|
||||
mode: "flee",
|
||||
actor: combatant.actor,
|
||||
label: f("MGNE.Roll.FleeLabel", { actor: combatant.name }),
|
||||
subtitle: f("MGNE.Roll.FleeSubtitle", { target }),
|
||||
roll,
|
||||
outcome: t(escaped ? "MGNE.Roll.FleeEscaped" : "MGNE.Roll.FleeKilled"),
|
||||
specialText: studyActions > 0
|
||||
? f("MGNE.Roll.FleeStudyActions", { count: studyActions })
|
||||
: t("MGNE.Roll.FleeNoStudyActions"),
|
||||
})
|
||||
|
||||
return { combatant, roll, escaped, target, studyActions }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import MGNERoll from "./roll.mjs"
|
||||
import { SYSTEM } from "../config/system.mjs"
|
||||
|
||||
export default class MGNEItem extends Item {
|
||||
prepareBaseData() {
|
||||
super.prepareBaseData()
|
||||
const fallbackIcon = SYSTEM.itemTypes[this.type]?.icon
|
||||
if (!fallbackIcon) return
|
||||
if (!this._source.img || this._source.img === "icons/svg/item-bag.svg") {
|
||||
this.updateSource({ img: fallbackIcon })
|
||||
}
|
||||
}
|
||||
|
||||
async rollUsage() {
|
||||
return MGNERoll.rollUsage(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { SYSTEM, SYSTEM_ID } from "../config/system.mjs"
|
||||
|
||||
const t = key => game.i18n.localize(key)
|
||||
const f = (key, data = {}) => game.i18n.format(key, data)
|
||||
|
||||
function joinParts(parts) {
|
||||
return parts.filter(Boolean).join(" ")
|
||||
}
|
||||
|
||||
function getChatModeLabel(mode) {
|
||||
return game.i18n.localize(`MGNE.Chat.Mode.${mode}`) || mode
|
||||
}
|
||||
|
||||
function pickRandom(list = []) {
|
||||
if (!list.length) return ""
|
||||
return list[Math.floor(Math.random() * list.length)]
|
||||
}
|
||||
|
||||
function stepDownDie(die, steps = 1, track = SYSTEM.usageDice) {
|
||||
let current = die
|
||||
for (let step = 0; step < steps; step += 1) {
|
||||
const index = track.indexOf(current)
|
||||
if (index === -1) return current
|
||||
current = track[Math.min(index + 1, track.length - 1)]
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function getFirstTargetActor() {
|
||||
const target = game.user?.targets ? Array.from(game.user.targets)[0] : null
|
||||
return target?.actor ?? null
|
||||
}
|
||||
|
||||
function serializeForm(form) {
|
||||
return Array.from(form.elements).reduce((acc, element) => {
|
||||
if (!element.name) return acc
|
||||
acc[element.name] = element.type === "checkbox" ? element.checked : element.value
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
function numericOptions(min, max, current = null) {
|
||||
const resolvedMin = Math.min(min, Number.isFinite(current) ? current : min)
|
||||
const resolvedMax = Math.max(max, Number.isFinite(current) ? current : max)
|
||||
return Array.from({ length: (resolvedMax - resolvedMin) + 1 }, (_, index) => {
|
||||
const value = resolvedMin + index
|
||||
return { value, label: String(value) }
|
||||
})
|
||||
}
|
||||
|
||||
async function renderCard(context) {
|
||||
const outcomeClass = `${context.outcome ?? ""}`
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
const eyebrow = context.eyebrow ?? getChatModeLabel(context.mode ?? "generic")
|
||||
const normalizedEyebrow = `${eyebrow}`.trim().toLowerCase()
|
||||
const normalizedLabel = `${context.label ?? ""}`.trim().toLowerCase()
|
||||
|
||||
return foundry.applications.handlebars.renderTemplate(`systems/${SYSTEM_ID}/templates/chat-message.hbs`, {
|
||||
...context,
|
||||
modeClass: context.mode ?? "generic",
|
||||
eyebrow: "",
|
||||
outcomeClass,
|
||||
})
|
||||
}
|
||||
|
||||
export default class MGNERoll {
|
||||
static async promptCheck({ actor, abilityId, label, baseDR = 12, rollType = "check", item = null }) {
|
||||
const abilityLabel = SYSTEM.abilities[abilityId]?.label ?? abilityId
|
||||
const content = await foundry.applications.handlebars.renderTemplate(`systems/${SYSTEM_ID}/templates/roll-dialog.hbs`, {
|
||||
actorName: actor.name,
|
||||
label,
|
||||
abilityLabel,
|
||||
baseDR,
|
||||
drOptions: numericOptions(6, 20, baseDR),
|
||||
modifierOptions: numericOptions(-6, 6, 0),
|
||||
omens: actor.system.omens?.current ?? 0,
|
||||
rollType,
|
||||
})
|
||||
|
||||
const dialogData = await foundry.applications.api.DialogV2.wait({
|
||||
window: { title: label },
|
||||
classes: ["mgne", "roll-dialog"],
|
||||
content,
|
||||
buttons: [
|
||||
{
|
||||
label: t("MGNE.Common.Roll"),
|
||||
icon: "fa-solid fa-dice-d20",
|
||||
callback: (_event, button) => serializeForm(button.form),
|
||||
},
|
||||
],
|
||||
rejectClose: false,
|
||||
})
|
||||
|
||||
if (!dialogData) return null
|
||||
|
||||
const modifier = Number.parseInt(dialogData.modifier ?? 0, 10) || 0
|
||||
const spendOmen = Boolean(dialogData.spendOmen)
|
||||
const dr = (Number.parseInt(dialogData.dr ?? baseDR, 10) || baseDR) - (spendOmen ? 4 : 0)
|
||||
const abilityValue = actor.system.abilities?.[abilityId]?.value ?? 0
|
||||
const sign = modifier >= 0 ? "+" : "-"
|
||||
const formula = modifier === 0 ? `1d20 + ${abilityValue}` : `1d20 + ${abilityValue} ${sign} ${Math.abs(modifier)}`
|
||||
const roll = await (new Roll(formula)).evaluate()
|
||||
const natural = roll.dice?.[0]?.results?.[0]?.result ?? roll.total
|
||||
|
||||
if (spendOmen && (actor.system.omens?.current ?? 0) > 0) {
|
||||
await actor.update({ "system.omens.current": Math.max(0, actor.system.omens.current - 1) })
|
||||
}
|
||||
|
||||
const critical = natural === 20
|
||||
const fumble = natural === 1
|
||||
const success = critical || (!fumble && roll.total >= dr)
|
||||
const outcome = critical ? t("MGNE.Roll.OutcomeCriticalSuccess") : fumble ? t("MGNE.Roll.OutcomeFumble") : success ? t("MGNE.Roll.OutcomeSuccess") : t("MGNE.Roll.OutcomeFailure")
|
||||
|
||||
let specialText = ""
|
||||
if (critical && rollType === "resonance") specialText = pickRandom(SYSTEM.tables.eucatastrophe)
|
||||
else if (fumble && rollType === "resonance") specialText = pickRandom(SYSTEM.tables.catastrophe)
|
||||
else if (critical) {
|
||||
specialText = rollType === "attack"
|
||||
? t("MGNE.Roll.CriticalAttack")
|
||||
: rollType === "defense"
|
||||
? t("MGNE.Roll.DefenseCritical")
|
||||
: pickRandom(SYSTEM.tables.triumphs)
|
||||
} else if (fumble) {
|
||||
specialText = rollType === "attack" ? t("MGNE.Roll.AttackFumble") : rollType === "defense" ? t("MGNE.Roll.DefenseFumble") : pickRandom(SYSTEM.tables.mishaps)
|
||||
}
|
||||
|
||||
const showDamageButton = rollType === "attack" && (success || critical) && !!item
|
||||
const contentHtml = await renderCard({
|
||||
mode: "check",
|
||||
actorName: actor.name,
|
||||
actorImg: actor.img,
|
||||
label,
|
||||
subtitle: f("MGNE.Roll.CheckSubtitle", { ability: abilityLabel, dr }),
|
||||
formula: roll.formula,
|
||||
total: roll.total,
|
||||
outcome,
|
||||
specialText,
|
||||
showDamageButton,
|
||||
damageActorId: showDamageButton ? actor.id : null,
|
||||
damageItemId: showDamageButton ? item.id : null,
|
||||
damageFormula: showDamageButton ? (item.system.damage || "1") : null,
|
||||
damageCritical: showDamageButton && critical,
|
||||
})
|
||||
|
||||
await ChatMessage.create({
|
||||
speaker: ChatMessage.getSpeaker({ actor }),
|
||||
rolls: [roll],
|
||||
content: contentHtml,
|
||||
})
|
||||
|
||||
return { roll, success, critical, fumble, outcome, specialText, dr, abilityId, item }
|
||||
}
|
||||
|
||||
static async rollMorale(actor) {
|
||||
const target = actor.system.morale ?? 7
|
||||
const roll = await (new Roll("2d6")).evaluate()
|
||||
const broken = roll.total >= target
|
||||
const contentHtml = await renderCard({
|
||||
mode: "morale",
|
||||
actorName: actor.name,
|
||||
actorImg: actor.img,
|
||||
label: t("MGNE.Roll.MoraleCheck"),
|
||||
subtitle: f("MGNE.Roll.TargetSubtitle", { target }),
|
||||
formula: roll.formula,
|
||||
total: roll.total,
|
||||
outcome: broken ? t("MGNE.Roll.OutcomeBroken") : t("MGNE.Roll.OutcomeSteady"),
|
||||
specialText: broken ? t("MGNE.Roll.MoraleBrokenText") : "",
|
||||
})
|
||||
|
||||
await ChatMessage.create({
|
||||
speaker: ChatMessage.getSpeaker({ actor }),
|
||||
rolls: [roll],
|
||||
content: contentHtml,
|
||||
})
|
||||
|
||||
return { roll, broken }
|
||||
}
|
||||
|
||||
static async rollDamage({ actor, item }) {
|
||||
const damageBonus = await actor.consumePendingDamageBonus(item.id)
|
||||
const multiplier = damageBonus?.multiplier ?? 1
|
||||
const baseFormula = item.system.damage || "1"
|
||||
const formula = multiplier > 1 ? `${multiplier} * (${baseFormula})` : baseFormula
|
||||
const roll = await (new Roll(formula)).evaluate()
|
||||
const isCritical = multiplier > 1
|
||||
const contentHtml = await renderCard({
|
||||
mode: "damage",
|
||||
actorName: actor.name,
|
||||
actorImg: actor.img,
|
||||
label: `${item.name} Damage`,
|
||||
subtitle: null,
|
||||
formula: roll.formula,
|
||||
total: roll.total,
|
||||
outcome: t("MGNE.Roll.OutcomeRolled"),
|
||||
specialText: isCritical ? t("MGNE.Roll.CriticalDamageApplied") : "",
|
||||
showApplyButton: true,
|
||||
damageTotal: roll.total,
|
||||
damageCritical: isCritical,
|
||||
})
|
||||
|
||||
await ChatMessage.create({
|
||||
speaker: ChatMessage.getSpeaker({ actor }),
|
||||
rolls: [roll],
|
||||
content: contentHtml,
|
||||
})
|
||||
|
||||
return { roll }
|
||||
}
|
||||
|
||||
static async rollFlatDamage({ actor, label, formula }) {
|
||||
const damageBonus = await actor.consumePendingDamageBonus("profile-attack")
|
||||
const multiplier = damageBonus?.multiplier ?? 1
|
||||
const baseFormula = formula || "1"
|
||||
const resolvedFormula = multiplier > 1 ? `${multiplier} * (${baseFormula})` : baseFormula
|
||||
const roll = await (new Roll(resolvedFormula)).evaluate()
|
||||
const isCritical = multiplier > 1
|
||||
const contentHtml = await renderCard({
|
||||
mode: "damage",
|
||||
actorName: actor.name,
|
||||
actorImg: actor.img,
|
||||
label,
|
||||
subtitle: null,
|
||||
formula: roll.formula,
|
||||
total: roll.total,
|
||||
outcome: t("MGNE.Roll.OutcomeRolled"),
|
||||
specialText: isCritical ? t("MGNE.Roll.CriticalDamageApplied") : "",
|
||||
showApplyButton: true,
|
||||
damageTotal: roll.total,
|
||||
damageCritical: isCritical,
|
||||
})
|
||||
|
||||
await ChatMessage.create({
|
||||
speaker: ChatMessage.getSpeaker({ actor }),
|
||||
rolls: [roll],
|
||||
content: contentHtml,
|
||||
})
|
||||
|
||||
return { roll }
|
||||
}
|
||||
|
||||
static async rollUsage(item) {
|
||||
const currentDie = item.system.usageDie
|
||||
if (!currentDie || currentDie === "depleted") {
|
||||
ui.notifications.warn(f("MGNE.Notification.ItemDepleted", { item: item.name }))
|
||||
return null
|
||||
}
|
||||
|
||||
const roll = await (new Roll(`1${currentDie}`)).evaluate()
|
||||
const depleted = roll.total <= 2
|
||||
const nextDie = depleted ? stepDownDie(currentDie) : currentDie
|
||||
const updates = { "system.usageDie": nextDie }
|
||||
|
||||
if (item.type === "resonance-core" && nextDie === "depleted") updates["system.burnedOut"] = true
|
||||
await item.update(updates)
|
||||
|
||||
const contentHtml = await renderCard({
|
||||
mode: "usage",
|
||||
actorName: item.parent?.name ?? item.name,
|
||||
actorImg: item.img,
|
||||
label: f("MGNE.Roll.ItemUsageLabel", { item: item.name }),
|
||||
subtitle: f("MGNE.Roll.CurrentDie", { die: currentDie.toUpperCase() }),
|
||||
formula: roll.formula,
|
||||
total: roll.total,
|
||||
outcome: depleted ? f("MGNE.Roll.DowngradedTo", { die: nextDie.toUpperCase() }) : t("MGNE.Roll.NoChange"),
|
||||
specialText: depleted && nextDie === "depleted" ? t("MGNE.Roll.ItemNowDepleted") : "",
|
||||
})
|
||||
|
||||
await ChatMessage.create({
|
||||
speaker: ChatMessage.getSpeaker({ actor: item.parent ?? null }),
|
||||
rolls: [roll],
|
||||
content: contentHtml,
|
||||
})
|
||||
|
||||
return { roll, depleted, nextDie }
|
||||
}
|
||||
|
||||
static async applyDamageCard({ actor, sourceActor = null, sourceItem = null, amount, armorRoll = null, appliedDamage, newHp, breakText = "", defenseFumbleText = "", criticalArmorText = "" }) {
|
||||
const contentHtml = await renderCard({
|
||||
mode: "apply-damage",
|
||||
actorName: actor.name,
|
||||
actorImg: actor.img,
|
||||
label: f("MGNE.Roll.TakesDamageLabel", { actor: actor.name }),
|
||||
subtitle: sourceItem
|
||||
? (sourceActor
|
||||
? f("MGNE.Roll.DamageSourceWithActor", { item: sourceItem.name, actor: sourceActor.name })
|
||||
: f("MGNE.Roll.DamageSourceItem", { item: sourceItem.name }))
|
||||
: t("MGNE.Roll.DirectDamage"),
|
||||
formula: armorRoll?.formula ?? "",
|
||||
total: amount,
|
||||
outcome: f("MGNE.Roll.HPNow", { hp: newHp }),
|
||||
specialText: joinParts([
|
||||
defenseFumbleText,
|
||||
armorRoll ? f("MGNE.Roll.ArmorAbsorbed", { amount: armorRoll.total }) : "",
|
||||
f("MGNE.Roll.AppliedDamageText", { amount: appliedDamage }),
|
||||
criticalArmorText,
|
||||
breakText ? f("MGNE.Roll.BreakText", { text: breakText }) : "",
|
||||
]),
|
||||
})
|
||||
|
||||
await ChatMessage.create({
|
||||
speaker: ChatMessage.getSpeaker({ actor }),
|
||||
rolls: armorRoll ? [armorRoll] : [],
|
||||
content: contentHtml,
|
||||
})
|
||||
}
|
||||
|
||||
static async createRestCard({ actor, label, subtitle, roll, outcome, specialText = "" }) {
|
||||
return this.createActionCard({ mode: "rest", actor, label, subtitle, roll, outcome, specialText })
|
||||
}
|
||||
|
||||
static async createActionCard({ mode = "action", actor = null, label, subtitle = "", roll, outcome, specialText = "" }) {
|
||||
const contentHtml = await renderCard({
|
||||
mode,
|
||||
actorName: actor?.name ?? "",
|
||||
actorImg: actor?.img ?? "",
|
||||
label,
|
||||
subtitle,
|
||||
formula: roll.formula,
|
||||
total: roll.total,
|
||||
outcome,
|
||||
specialText,
|
||||
})
|
||||
|
||||
await ChatMessage.create({
|
||||
speaker: ChatMessage.getSpeaker({ actor }),
|
||||
rolls: [roll],
|
||||
content: contentHtml,
|
||||
})
|
||||
}
|
||||
|
||||
static getFirstTargetActor() {
|
||||
return getFirstTargetActor()
|
||||
}
|
||||
|
||||
static stepDownDie(die, steps = 1, track = SYSTEM.usageDice) {
|
||||
return stepDownDie(die, steps, track)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { default as MGNECharacter } from "./character.mjs"
|
||||
export { default as MGNECreature } from "./creature.mjs"
|
||||
export { default as MGNECompanion } from "./companion.mjs"
|
||||
export { default as MGNEWeapon } from "./weapon.mjs"
|
||||
export { default as MGNEArmor } from "./armor.mjs"
|
||||
export { default as MGNEShield } from "./shield.mjs"
|
||||
export { default as MGNEEquipment } from "./equipment.mjs"
|
||||
export { default as MGNEResonanceCore } from "./resonance-core.mjs"
|
||||
export { default as MGNEArtifact } from "./artifact.mjs"
|
||||
export { default as MGNEFeature } from "./feature.mjs"
|
||||
@@ -0,0 +1,22 @@
|
||||
import { SYSTEM } from "../config/system.mjs"
|
||||
import { booleanField, htmlField, numberField } from "./shared.mjs"
|
||||
|
||||
export default class MGNEArmor extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
return {
|
||||
description: htmlField(""),
|
||||
armorDie: new foundry.data.fields.StringField({
|
||||
required: true,
|
||||
nullable: false,
|
||||
initial: "d4",
|
||||
choices: SYSTEM.armorDieChoices,
|
||||
}),
|
||||
penalty: numberField(0, 0, 6),
|
||||
equipped: booleanField(false),
|
||||
broken: booleanField(false),
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["MGNE.Armor"]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SYSTEM } from "../config/system.mjs"
|
||||
import { booleanField, htmlField, stringField } from "./shared.mjs"
|
||||
|
||||
export default class MGNEArtifact extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
return {
|
||||
description: htmlField(""),
|
||||
artifactId: new foundry.data.fields.StringField({
|
||||
required: true,
|
||||
nullable: false,
|
||||
initial: "shiver-lens",
|
||||
choices: SYSTEM.artifactChoices,
|
||||
}),
|
||||
synchronized: booleanField(false),
|
||||
synchronizedTo: stringField(""),
|
||||
usageDie: new foundry.data.fields.StringField({
|
||||
required: true,
|
||||
nullable: false,
|
||||
initial: "d6",
|
||||
choices: SYSTEM.usageDieChoices,
|
||||
}),
|
||||
broken: booleanField(false),
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["MGNE.Artifact"]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { SYSTEM } from "../config/system.mjs"
|
||||
import { abilitySchema, booleanField, conditionSchema, htmlField, numberField, stringField, trackSchema } from "./shared.mjs"
|
||||
|
||||
export default class MGNECharacter extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const fields = foundry.data.fields
|
||||
|
||||
return {
|
||||
abilities: abilitySchema(),
|
||||
hp: trackSchema(1, 1),
|
||||
omens: new fields.SchemaField({
|
||||
current: numberField(0, 0),
|
||||
die: new fields.StringField({ required: true, nullable: false, initial: "d2", choices: SYSTEM.omenDieChoices }),
|
||||
}),
|
||||
resonance: new fields.SchemaField({
|
||||
max: numberField(1, 0),
|
||||
used: numberField(0, 0),
|
||||
blocked: booleanField(false),
|
||||
}),
|
||||
artifactSync: new fields.SchemaField({
|
||||
used: numberField(0, 0),
|
||||
}),
|
||||
survival: new fields.SchemaField({
|
||||
salvationUsed: booleanField(false),
|
||||
}),
|
||||
conditions: conditionSchema(),
|
||||
carryCapacity: numberField(8, 0),
|
||||
rations: numberField(0, 0),
|
||||
kiffol: numberField(0, 0),
|
||||
background: stringField(""),
|
||||
origin: stringField(""),
|
||||
scars: stringField(""),
|
||||
motivation: stringField(""),
|
||||
vice: stringField(""),
|
||||
description: htmlField(""),
|
||||
notes: htmlField(""),
|
||||
}
|
||||
}
|
||||
|
||||
prepareDerivedData() {
|
||||
super.prepareDerivedData()
|
||||
|
||||
this.carryCapacity = (this.abilities.strength?.value ?? 0) + 8
|
||||
this.resonance.remaining = Math.max(0, (this.resonance.max ?? 0) - (this.resonance.used ?? 0))
|
||||
this.syncLimit = Math.max(0, this.abilities.toughness?.value ?? 0)
|
||||
this.syncRemaining = Math.max(0, this.syncLimit - (this.artifactSync.used ?? 0))
|
||||
this.armorFormula = this.parent?.getArmorRollFormula?.() ?? "0"
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["MGNE.Character"]
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { SYSTEM } from "../config/system.mjs"
|
||||
import { abilitySchema, htmlField, numberField, stringField, trackSchema } from "./shared.mjs"
|
||||
|
||||
export default class MGNECompanion extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const fields = foundry.data.fields
|
||||
|
||||
return {
|
||||
abilities: abilitySchema(),
|
||||
hp: trackSchema(1, 1),
|
||||
morale: numberField(7, 2, 12),
|
||||
armor: new fields.SchemaField({
|
||||
die: new fields.StringField({ required: true, nullable: false, initial: "0", choices: SYSTEM.armorDieChoices }),
|
||||
}),
|
||||
attack: new fields.SchemaField({
|
||||
label: stringField("Attack"),
|
||||
damage: stringField("1d4"),
|
||||
}),
|
||||
valueText: stringField(""),
|
||||
traitText: stringField(""),
|
||||
specialtyText: stringField(""),
|
||||
adventuringBehavior: htmlField(""),
|
||||
combatBehavior: htmlField(""),
|
||||
upkeep: stringField("3d10c per full rest"),
|
||||
description: htmlField(""),
|
||||
notes: htmlField(""),
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["MGNE.Companion"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { SYSTEM } from "../config/system.mjs"
|
||||
import { abilitySchema, htmlField, numberField, stringField, trackSchema } from "./shared.mjs"
|
||||
|
||||
export default class MGNECreature extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
const fields = foundry.data.fields
|
||||
|
||||
return {
|
||||
abilities: abilitySchema(),
|
||||
hp: trackSchema(1, 1),
|
||||
morale: numberField(7, 2, 12),
|
||||
armor: new fields.SchemaField({
|
||||
die: new fields.StringField({ required: true, nullable: false, initial: "0", choices: SYSTEM.armorDieChoices }),
|
||||
}),
|
||||
attack: new fields.SchemaField({
|
||||
label: stringField("Attack"),
|
||||
damage: stringField("1d4"),
|
||||
}),
|
||||
description: htmlField(""),
|
||||
special: htmlField(""),
|
||||
notes: htmlField(""),
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["MGNE.Creature"]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { SYSTEM } from "../config/system.mjs"
|
||||
import { booleanField, htmlField, numberField } from "./shared.mjs"
|
||||
|
||||
export default class MGNEEquipment extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
return {
|
||||
description: htmlField(""),
|
||||
subtype: new foundry.data.fields.StringField({
|
||||
required: true,
|
||||
nullable: false,
|
||||
initial: "gear",
|
||||
choices: SYSTEM.equipmentSubtypes,
|
||||
}),
|
||||
quantity: numberField(1, 0),
|
||||
carried: booleanField(true),
|
||||
equipped: booleanField(false),
|
||||
usageDie: new foundry.data.fields.StringField({
|
||||
required: true,
|
||||
nullable: false,
|
||||
initial: "d6",
|
||||
choices: SYSTEM.usageDieChoices,
|
||||
}),
|
||||
consumable: booleanField(false),
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["MGNE.Equipment"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { SYSTEM } from "../config/system.mjs"
|
||||
import { htmlField } from "./shared.mjs"
|
||||
|
||||
export default class MGNEFeature extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
return {
|
||||
featureId: new foundry.data.fields.StringField({
|
||||
required: true,
|
||||
nullable: false,
|
||||
initial: "akimbo-hit-priest",
|
||||
choices: SYSTEM.featureChoices,
|
||||
}),
|
||||
description: htmlField(""),
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["MGNE.Feature"]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SYSTEM } from "../config/system.mjs"
|
||||
import { booleanField, htmlField, stringField } from "./shared.mjs"
|
||||
|
||||
export default class MGNEResonanceCore extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
return {
|
||||
description: htmlField(""),
|
||||
resonationId: new foundry.data.fields.StringField({
|
||||
required: true,
|
||||
nullable: false,
|
||||
initial: "accelerate",
|
||||
choices: SYSTEM.resonanceList,
|
||||
}),
|
||||
usageDie: new foundry.data.fields.StringField({
|
||||
required: true,
|
||||
nullable: false,
|
||||
initial: "d6",
|
||||
choices: SYSTEM.usageDieChoices,
|
||||
}),
|
||||
burnedOut: booleanField(false),
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["MGNE.ResonanceCore"]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { SYSTEM } from "../config/system.mjs"
|
||||
|
||||
export function htmlField(initial = "", options = {}) {
|
||||
return new foundry.data.fields.HTMLField({ required: true, initial, textSearch: true, ...options })
|
||||
}
|
||||
|
||||
export function stringField(initial = "", options = {}) {
|
||||
return new foundry.data.fields.StringField({ required: true, nullable: false, initial, ...options })
|
||||
}
|
||||
|
||||
export function numberField(initial = 0, min = 0, max = null, options = {}) {
|
||||
return new foundry.data.fields.NumberField({
|
||||
required: true,
|
||||
nullable: false,
|
||||
integer: true,
|
||||
initial,
|
||||
min,
|
||||
...(max === null ? {} : { max }),
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
export function booleanField(initial = false, options = {}) {
|
||||
return new foundry.data.fields.BooleanField({ required: true, initial, ...options })
|
||||
}
|
||||
|
||||
export function abilitySchema() {
|
||||
const fields = foundry.data.fields
|
||||
const schema = {}
|
||||
for (const abilityId of SYSTEM.abilityOrder) {
|
||||
const ability = SYSTEM.abilities[abilityId]
|
||||
schema[abilityId] = new fields.SchemaField(
|
||||
{
|
||||
label: stringField(ability.label, { label: "MGNE.Common.Label" }),
|
||||
value: numberField(0, -3, 6, { label: "MGNE.Common.Value" }),
|
||||
},
|
||||
{ label: ability.label }
|
||||
)
|
||||
}
|
||||
return new fields.SchemaField(schema)
|
||||
}
|
||||
|
||||
export function conditionSchema() {
|
||||
const fields = foundry.data.fields
|
||||
const schema = {}
|
||||
for (const condition of Object.values(SYSTEM.conditions)) {
|
||||
if (condition.hasValue) {
|
||||
schema[condition.id] = new fields.SchemaField(
|
||||
{ value: numberField(0, 0, 12, { label: "MGNE.Common.Value" }) },
|
||||
{ label: condition.label }
|
||||
)
|
||||
} else {
|
||||
schema[condition.id] = new fields.SchemaField(
|
||||
{ active: booleanField(false, { label: condition.label }) },
|
||||
{ label: condition.label }
|
||||
)
|
||||
}
|
||||
}
|
||||
return new fields.SchemaField(schema)
|
||||
}
|
||||
|
||||
export function trackSchema(initialValue = 0, initialMax = 0) {
|
||||
return new foundry.data.fields.SchemaField({
|
||||
value: numberField(initialValue, -99),
|
||||
max: numberField(initialMax, 0),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { SYSTEM } from "../config/system.mjs"
|
||||
import { booleanField, htmlField, numberField } from "./shared.mjs"
|
||||
|
||||
export default class MGNEShield extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
return {
|
||||
description: htmlField(""),
|
||||
armorDie: new foundry.data.fields.StringField({
|
||||
required: true,
|
||||
nullable: false,
|
||||
initial: "d4",
|
||||
choices: SYSTEM.armorDieChoices,
|
||||
}),
|
||||
penalty: numberField(0, 0, 4),
|
||||
equipped: booleanField(false),
|
||||
broken: booleanField(false),
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["MGNE.Shield"]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { SYSTEM } from "../config/system.mjs"
|
||||
import { booleanField, htmlField, numberField, stringField } from "./shared.mjs"
|
||||
|
||||
export default class MGNEWeapon extends foundry.abstract.TypeDataModel {
|
||||
static defineSchema() {
|
||||
return {
|
||||
description: htmlField(""),
|
||||
category: new foundry.data.fields.StringField({
|
||||
required: true,
|
||||
nullable: false,
|
||||
initial: "melee",
|
||||
choices: SYSTEM.weaponCategories,
|
||||
}),
|
||||
damage: stringField("1d4"),
|
||||
range: stringField("Touch"),
|
||||
properties: stringField(""),
|
||||
usageDie: new foundry.data.fields.StringField({
|
||||
required: true,
|
||||
nullable: false,
|
||||
initial: "d6",
|
||||
choices: SYSTEM.usageDieChoices,
|
||||
}),
|
||||
quantity: numberField(1, 0),
|
||||
equipped: booleanField(false),
|
||||
broken: booleanField(false),
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
static LOCALIZATION_PREFIXES = ["MGNE.Weapon"]
|
||||
}
|
||||
Reference in New Issue
Block a user