System development, WIP

This commit is contained in:
2026-05-05 13:55:42 +02:00
commit c0223977d2
250 changed files with 10362 additions and 0 deletions
+12
View File
@@ -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" },
}
}
+17
View File
@@ -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" },
}
}
+269
View File
@@ -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")
}
+4
View File
@@ -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"
+386
View File
@@ -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 }),
}
}
}
+139
View File
@@ -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 }
}
}
+17
View File
@@ -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)
}
}
+340
View File
@@ -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)
}
}
+10
View File
@@ -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"
+22
View File
@@ -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"]
}
+28
View File
@@ -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"]
}
+52
View File
@@ -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"]
}
+32
View File
@@ -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"]
}
+27
View File
@@ -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"]
}
+29
View File
@@ -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"]
}
+19
View File
@@ -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"]
}
+26
View File
@@ -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"]
}
+67
View File
@@ -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),
})
}
+22
View File
@@ -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"]
}
+31
View File
@@ -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"]
}