diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4b9dd06..8b5061a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
- node-version: "18"
+ node-version: "20"
cache: "yarn"
- run: |
diff --git a/.gitignore b/.gitignore
index 331193c..9203d64 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
dist/
node_modules/
.vscode/
+.vite-cache/
diff --git a/module.json b/module.json
index 79b3560..decc182 100644
--- a/module.json
+++ b/module.json
@@ -52,7 +52,7 @@
"dist/module.js"
],
"styles": [
- "styles/main.css"
+ "dist/style.css"
],
"flags": {
"hotReload": {
diff --git a/package.json b/package.json
index e6b5f2d..66e8c2f 100644
--- a/package.json
+++ b/package.json
@@ -4,19 +4,21 @@
"author": "oWave",
"license": "MIT",
"private": true,
+ "type": "module",
"scripts": {
- "watch": "esbuild src/index.ts --outfile=dist/module.js --sourcemap --bundle --watch",
- "build": "esbuild src/index.ts --outfile=dist/module.js --bundle",
- "lint": "eslint src/",
+ "dev": "vite serve",
+ "build": "vite build",
"unpack": "fvtt package workon \"pf2e-flatcheck-helper\" && fvtt package unpack \"effects\"",
"pack": "fvtt package workon \"pf2e-flatcheck-helper\" && fvtt package pack \"effects\""
},
"devDependencies": {
"@biomejs/biome": "^1.8.3",
"@types/jquery": "^3.5.30",
+ "@types/node": "^22.3.0",
"typescript": "*",
"vite": "^5.4.0",
- "vite-plugin-checker": "^0.7.2"
+ "vite-plugin-checker": "^0.7.2",
+ "vite-tsconfig-paths": "^5.0.1"
},
"packageManager": "yarn@1.22.19+sha1.4ba7fc5c6e704fce2066ecbfb0b0d8976fe62447",
"dependencies": {}
diff --git a/src/flat.ts b/src/flat.ts
deleted file mode 100644
index b5919fa..0000000
--- a/src/flat.ts
+++ /dev/null
@@ -1,108 +0,0 @@
-import type { TokenPF2e } from "types/pf2e/module/canvas"
-import Module from "./index"
-import module from "./index"
-import { MoreDialog } from "./more-dialog"
-
-export async function rollFlatCheck(
- dc: number,
- { hidden = false, label }: { hidden: boolean; label?: string },
-) {
- const r = await new Roll("d20").roll()
- const degree = r.total >= dc ? "success" : "failure"
- const delta = r.total - dc
- const deltaText = delta > 0 ? `+${delta}` : delta.toString()
-
- const flavor = document.createElement("span")
- flavor.classList.add("flavor-text")
- flavor.innerHTML = `
-
-
${label ?? "Flat"} DC: ${dc}
-
- Result: ${degree.capitalize()}
- by ${deltaText}
-
-
- `
-
- r.toMessage({ flavor: flavor.outerHTML }, { rollMode: hidden ? "blindroll" : "roll" })
-}
-
-export const CONDITION_DCS = {
- concealed: 5,
- hidden: 11,
- invisible: 11,
-}
-
-function dcForToken(token: TokenPF2e) {
- let dc = 0
- token.actor?.conditions.stored.forEach((c) => {
- dc = Math.max(CONDITION_DCS[c.slug] ?? 0, dc)
- })
- return dc || null
-}
-
-export async function rollForSingleTarget(
- target: TokenPF2e | undefined,
- { hidden = false }: { hidden: boolean },
-) {
- if (!target) return
- const dc = dcForToken(target)
- if (!dc) ui.notifications.warn("Selected target has no conditions that require a flat check")
- else rollFlatCheck(dc, { hidden })
-}
-
-export function setupFlat() {
- if (!module.settings.fcButtonsEnabled) return
-
- Hooks.on("renderSidebarTab", async (app: SidebarTab, html: HTMLCollection) => {
- if (app.tabName !== "chat") return
-
- const chat = html[0].querySelector("#chat-form")
-
- const template = await renderTemplate("modules/pf2e-flatcheck-helper/templates/buttons.hbs", {})
- const node = document.createElement("div")
- node.id = "fc-container"
- // node.style.flex = "0"
- node.innerHTML = template
-
- chat?.after(node)
- const $node = $(node)
- // @ts-expect-error
- $node.find(".tooltip").tooltipster({
- contentAsHTML: true,
- })
-
- node.querySelectorAll("button").forEach((button) =>
- button.addEventListener("click", function (e) {
- const value = this.dataset.dc
- if (!value) throw new Error(`Bad button DC value ${value}`)
- const hidden = e.ctrlKey
-
- if (value === "targets") {
- if (game.user?.targets.size === 0) return ui.notifications.warn("No targets selected")
- if (game.user?.targets.size === 1)
- return rollForSingleTarget(game.user.targets.first(), { hidden })
- else return ui.notifications.warn("Too many targets")
- } else if (value === "more") {
- new MoreDialog().render(true)
- } else {
- const dc = Number(value)
- if (Number.isNaN(dc)) throw new Error(`Bad button DC value ${value}`)
-
- rollFlatCheck(dc, { hidden })
- }
- }),
- )
- })
-
- Hooks.on("targetToken", (user) => {
- if (user.id !== game.user?.id) return
- if (game.user.targets.size !== 1)
- return document.querySelector("#fc-button-target")?.classList.remove("highlight")
- const effectSlugs = Object.keys(CONDITION_DCS)
-
- if (game.user?.targets?.first()?.actor?.conditions.some((c) => effectSlugs.includes(c.slug)))
- document.querySelector("#fc-button-target")?.classList.add("highlight")
- else document.querySelector("#fc-button-target")?.classList.remove("highlight")
- })
-}
diff --git a/src/index.ts b/src/index.ts
index e8051f9..9837c6f 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,9 +1,9 @@
import { MODULE_ID } from "./constants"
-import { setupDelay } from "./delay"
-import { setupEmanationAutomation } from "./emanation"
-import { setupFlat } from "./flat"
-import { setupLink } from "./life-link"
+import { DelayModule } from "./modules/delay"
+import { EmanationModule } from "./modules/emanation/emanation"
import { settings } from "./settings"
+import { FlatModule } from "./modules/flat/flat"
+import { LifeLinkModule } from "./modules/life-link"
type Callback = (data: any) => void
@@ -21,6 +21,9 @@ class SocketHandler {
register(type: string, handler: Callback) {
this.#callbacks[type] = handler
}
+ unregister(type: string) {
+ delete this.#callbacks[type]
+ }
emit(type: string, data: any) {
if (!(type in this.#callbacks)) {
@@ -33,24 +36,51 @@ class SocketHandler {
}
}
-const module = {
+const MODULE = {
socketHandler: new SocketHandler(),
settings,
+ modules: {
+ flat: new FlatModule(),
+ delay: new DelayModule(),
+ emanation: new EmanationModule(),
+ lifeLink: new LifeLinkModule(),
+ },
}
-export default module
+export default MODULE
Hooks.on("init", () => {
- module.settings.init()
- module.socketHandler.init()
+ MODULE.settings.init()
+ MODULE.socketHandler.init()
+
+ for (const [name, module] of Object.entries(MODULE.modules)) {
+ const enabled =
+ module.settingsKey == null
+ ? true
+ : (game.settings.get(MODULE_ID, module.settingsKey) as boolean)
- setupDelay()
- setupFlat()
- setupLink()
+ if (enabled) module.enable()
+ }
})
Hooks.on("ready", () => {
- setupEmanationAutomation()
+ for (const [name, module] of Object.entries(MODULE.modules)) {
+ if (module.enabled) module.onReady()
+ }
+})
+
+Hooks.on("updateSetting", (setting: { key: string }, data) => {
+ if (!setting.key.startsWith(MODULE_ID)) return
+
+ const key = setting.key.split(".", 2).at(1)
+ if (!key) return
+
+ for (const m of Object.values(MODULE.modules).filter((m) => m.settingsKey === key)) {
+ if (data.value === "true") {
+ m.enable()
+ if (m.enabled) m.onReady()
+ } else if (data.value === "false") m.disable()
+ }
})
Hooks.on("renderSettingsConfig", (app: SettingsConfig, $html: JQuery) => {
diff --git a/src/life-link.ts b/src/life-link.ts
deleted file mode 100644
index 8be2462..0000000
--- a/src/life-link.ts
+++ /dev/null
@@ -1,318 +0,0 @@
-import type { ActorPF2e } from "types/pf2e/module/actor"
-import type { ChatMessagePF2e } from "types/pf2e/module/chat-message"
-import type { CombatantPF2e, EncounterPF2e } from "types/pf2e/module/encounter"
-import type { EffectPF2e, ItemPF2e } from "types/pf2e/module/item"
-import Module from "./index"
-import module from "./index"
-import { actorEffectBySlug, actorHasEffect } from "./utils"
-
-interface ButtonArgs {
- // HP to transfer from source to target
- // Stops if source doesn't have enough HP
- transfer?: number
- // HP to restore to target
- heal?: number
- // HP to remove from source
- dmg?: number
- source: string
- target: string
- cd?: 1
-}
-
-const UNDO_BUTTON_MARKUP = ``
-
-function makeButton(label: string, args: ButtonArgs) {
- const json = JSON.stringify(args)
- return `
-
-
- ${label}
-
- `
-}
-
-async function updateHP(actor, delta) {
- const hp = actor.system.attributes.hp.value
- await actor.update({
- "system.attributes.hp.value": hp + delta,
- })
-}
-
-async function handleTransferButton(args: ButtonArgs) {
- const required = ["source", "target"]
- for (const k of required) {
- // biome-ignore lint/style/useTemplate:
- if (!(k in args)) return ui.notifications.error("Missing arg " + k)
- }
-
- const source = fromUuidSync(args.source)
- const target = fromUuidSync(args.target)
- if (!target) return ui.notifications.error("No target actor")
- if (!source) return ui.notifications.error("No source actor")
- if (source.id === target.id) return ui.notifications.error("Can't transfer damage to self!")
-
- let transfer = 0
-
- if (args.transfer) {
- const missingHP =
- // @ts-expect-error pf2e
- target.system.attributes.hp.max - target.system.attributes.hp.value
- const maxTransfer = Math.min(Number(args.transfer), missingHP)
-
- // @ts-expect-error pf2e
- const hpRemaining = source.system.attributes.hp.value
- transfer = Math.min(maxTransfer, hpRemaining)
-
- if (transfer <= 0) return ui.notifications.warn("No HP remaining to transfer.")
- }
-
- let heal = transfer
- let dmg = transfer
-
- // If the HP transfer (from life link) reduces the source to 0, share life no longer applies
- // @ts-expect-error pf2e
- if (transfer < source.system.attributes.hp.value) {
- heal += args.heal ?? 0
- dmg += args.dmg ?? 0
- }
- await updateHP(source, -dmg)
- await updateHP(target, heal)
-
- if (args.cd) {
- await target.createEmbeddedDocuments("Item", [
- {
- type: "effect",
- name: "Life Link CD",
- img: "systems/pf2e/icons/spells/life-link.webp",
- system: {
- tokenIcon: { show: true },
- duration: {
- value: 1,
- unit: "rounds",
- sustained: false,
- expiry: "turn-start",
- },
- slug: "life-link-cd",
- },
- },
- ])
- }
-
- // @ts-expect-error Using uuids as keys doesn't work, but this does. Only question is when does this break
- await ChatMessage.create({
- content: `
- ${target.name} +${heal} HP
- 🡰
- ${source.name} -${dmg} HP
-
- ${UNDO_BUTTON_MARKUP}
- `,
- flags: {
- undo: [
- [source.uuid, dmg],
- [target.uuid, -heal],
- ],
- },
- })
-}
-
-function handleSpiritLink(effect: EffectPF2e) {
- const { actor, origin } = effect
-
- if (!actor) {
- return null
- }
-
- if (!origin || origin.id === actor.id) {
- ui.notifications.error(
- `Bad origin actor for Spirit Linked effect on ${actor.name}! See module readme.`,
- )
- return null
- }
-
- const transfer = effect.level * 2
- const missingHP = actor.system.attributes.hp!.max - actor.system.attributes.hp!.value
- if (missingHP <= 0) return null
-
- return makeButton(`${transfer} HP to ${actor.name}`, {
- transfer,
- source: origin.uuid,
- target: actor.uuid,
- })
-}
-
-export function setupLink() {
- Hooks.on<[CombatantPF2e, EncounterPF2e]>("pf2e.startTurn", async (combatant) => {
- if (!module.settings.lifeLinkEnabled) return
- if (game?.users?.activeGM?.id !== game.user?.id) return
-
- const links: string[] = []
-
- canvas.scene?.tokens.forEach(({ actor }) => {
- if (!actor) return
- const e = actorEffectBySlug(actor, "spirit-linked")
- if (!e) return
- if (combatant.actor?.id !== e.origin?.id) return
-
- const link = handleSpiritLink(e)
- if (link) links.push(link)
- })
-
- const content = `Spirit Link
${links.join("
")}`
-
- if (links.length) {
- await ChatMessage.create({
- content: content,
- whisper: ChatMessage.getWhisperRecipients("GM").map((u) => u.id),
- speaker: ChatMessage.getSpeaker({ actor: combatant.actor }),
- })
- }
- })
-
- Hooks.on<[ItemPF2e]>("createItem", async (item) => {
- if (item.isOfType("effect") && item.slug === "spirit-linked") {
- const link = handleSpiritLink(item)
- if (link) {
- await ChatMessage.create({
- content: `Spirit Link
${link}`,
- whisper: ChatMessage.getWhisperRecipients("GM").map((u) => u.id),
- speaker: ChatMessage.getSpeaker({ actor: item.actor }),
- })
- }
- }
- })
-
- Hooks.on("createChatMessage", async (msg) => {
- if (!module.settings.lifeLinkEnabled) return
- if (game.users?.activeGM?.id !== game.user?.id) return
-
- const flags = (msg).flags?.pf2e?.appliedDamage
- const uuid = flags?.uuid
- const dmg = flags?.updates.find((e) => e.path === "system.attributes.hp.value")?.value
- if (!uuid || !dmg || dmg <= 0) return
-
- const actor = fromUuidSync(uuid) as ActorPF2e
- if (!actor) return
-
- let lifeLinkTransfer = 0
-
- const lifeLinkEffect = actorEffectBySlug(actor, "life-linked")
- if (lifeLinkEffect && !actorHasEffect(actor, "life-link-cd")) {
- lifeLinkTransfer = (() => {
- if (!lifeLinkEffect.origin || lifeLinkEffect.origin.id === actor.id) {
- ui.notifications.error(
- `Bad origin actor for Life Linked effect on ${actor.name}! See module readme.`,
- {
- permanent: true,
- },
- )
- return 0
- }
-
- let maxTransfer = 3
- if (module.settings.lifeLinkVariant === "plus")
- maxTransfer = 2 + Math.floor((lifeLinkEffect.level - 1) / 2) * 3
- else {
- if (lifeLinkEffect.level >= 3) maxTransfer = 5
- if (lifeLinkEffect.level >= 6) maxTransfer = 10
- if (lifeLinkEffect.level >= 9) maxTransfer = 15
- }
-
- return Math.min(maxTransfer, dmg)
- })()
- }
-
- const shareLifeEffect = actorEffectBySlug(actor, "share-life")
-
- if (shareLifeEffect && !shareLifeEffect?.origin)
- ui.notifications.error(
- `Bad origin actor for Share Life effect on ${actor.name}! See module readme.`,
- {
- permanent: true,
- },
- )
-
- const buttons: string[] = []
- ;(() => {
- if (shareLifeEffect && lifeLinkTransfer) {
- const remainingDmg = dmg - lifeLinkTransfer
-
- if (
- shareLifeEffect?.origin &&
- lifeLinkEffect?.origin &&
- shareLifeEffect.origin.uuid === lifeLinkEffect.origin.uuid
- ) {
- // Both effects from the same source -> One Button
- buttons.push(
- makeButton(
- `${Math.ceil(remainingDmg / 2) + lifeLinkTransfer} to ${lifeLinkEffect.origin.name}`,
- {
- transfer: lifeLinkTransfer,
- heal: remainingDmg === 1 ? 1 : Math.floor(remainingDmg / 2),
- dmg: Math.ceil(remainingDmg / 2),
- cd: 1,
- source: lifeLinkEffect.origin.uuid,
- target: actor.uuid,
- },
- ),
- )
- return
- }
- }
- // return above means this is unreachable if both effects are from the same source
- if (shareLifeEffect?.origin) {
- const remainingDmg = dmg - lifeLinkTransfer
- // Button for Share Life
- if (remainingDmg)
- buttons.push(
- makeButton(
- `(Share Life) ${Math.ceil(remainingDmg / 2)} to ${shareLifeEffect.origin.name}`,
- {
- heal: remainingDmg === 1 ? 1 : Math.floor(remainingDmg / 2),
- dmg: Math.ceil(remainingDmg / 2),
- source: shareLifeEffect.origin.uuid,
- target: actor.uuid,
- },
- ),
- )
- }
- if (lifeLinkEffect?.origin && lifeLinkTransfer) {
- buttons.push(
- makeButton(`(Life Link) ${lifeLinkTransfer} to ${lifeLinkEffect.origin.name}`, {
- transfer: lifeLinkTransfer,
- cd: 1,
- source: lifeLinkEffect.origin.uuid,
- target: actor.uuid,
- }),
- )
- }
- })()
-
- if (buttons.length) {
- await ChatMessage.create({
- content: `Damage Transfer
${buttons.join("
")}`,
- whisper: ChatMessage.getWhisperRecipients("GM").map((u) => u.id),
- speaker: ChatMessage.getSpeaker(actor),
- })
- }
- })
-
- Hooks.on("renderChatMessage", (msg, html) => {
- if (!game.user?.isGM) return
- html.find("a.life-link").on("click", async (event) => {
- const args = JSON.parse(event.target.dataset.args!) as ButtonArgs
- await handleTransferButton(args)
- })
- html.find("button.fc-undo-button").on("click", async () => {
- const data = msg.flags.undo as unknown as [string, number][]
- for (const [uuid, dmg] of data) {
- const actor = await fromUuid(uuid)
- await updateHP(actor, dmg)
- }
- html.find(".undo-text").addClass("undo")
- await msg.update({
- content: html.find(".message-content").html(),
- })
- })
- })
-}
diff --git a/styles/main.css b/src/main.css
similarity index 83%
rename from styles/main.css
rename to src/main.css
index 6316b78..0e6b07d 100644
--- a/styles/main.css
+++ b/src/main.css
@@ -79,3 +79,18 @@
border: unset;
}
}
+
+#pf2e-hud-tracker .combatant .initiative {
+ .delay-indicator {
+ animation: rotating 4s linear infinite;
+ }
+
+ .delay-return .delay-indicator:hover {
+ animation: unset;
+
+ &:before {
+ /* fa-play */
+ content: "\f04b";
+ }
+ }
+}
diff --git a/src/modules/base.ts b/src/modules/base.ts
new file mode 100644
index 0000000..79410cf
--- /dev/null
+++ b/src/modules/base.ts
@@ -0,0 +1,63 @@
+import MODULE from "src/index"
+import { MODULE_ID } from "src/constants"
+
+export abstract class BaseModule {
+ enabled = false
+ hooks: Record = {}
+ wrappers: number[] = []
+ sockets: string[] = []
+ settingListeners: string[] = []
+
+ abstract readonly settingsKey: string | null
+
+ onReady() {}
+
+ enable() {
+ this.enabled = true
+ }
+ disable() {
+ this.enabled = false
+
+ for (const [hook, id] of Object.entries(this.hooks)) {
+ // @ts-expect-error
+ Hooks.off(hook, id)
+ }
+ this.hooks = {}
+
+ for (const id of this.wrappers) {
+ libWrapper.unregister(MODULE_ID, id)
+ }
+ this.wrappers = []
+
+ for (const type of this.sockets) {
+ MODULE.socketHandler.unregister(type)
+ }
+ this.sockets = []
+
+ for (const key of this.settingListeners) {
+ MODULE.settings.removeListener(key)
+ }
+ this.settingListeners = []
+ }
+
+ registerHook(hook: string, callback: HookCallback) {
+ this.hooks[hook] = Hooks.on(hook, callback)
+ }
+
+ registerWrapper(
+ target: string,
+ callback: CallableFunction,
+ mode: "MIXED" | "WRAPPER" | "OVERRIDE",
+ ) {
+ this.wrappers.push(libWrapper.register(MODULE_ID, target, callback, mode))
+ }
+
+ registerSocket(type: string, callback: (data: any) => void) {
+ this.sockets.push(type)
+ MODULE.socketHandler.register(type, callback)
+ }
+
+ registerSettingListener(key: string, callback: (value: unknown) => void) {
+ MODULE.settings.addListener(key, callback)
+ }
+}
diff --git a/src/delay.ts b/src/modules/delay/index.ts
similarity index 51%
rename from src/delay.ts
rename to src/modules/delay/index.ts
index 71fc95b..5ce4981 100644
--- a/src/delay.ts
+++ b/src/modules/delay/index.ts
@@ -1,59 +1,128 @@
-import type { ActorPF2e } from "types/pf2e/module/actor"
+import type { CombatantPF2e, EncounterPF2e, RolledCombatant } from "types/pf2e/module/encounter"
import type { ChatMessagePF2e } from "types/pf2e/module/chat-message"
-import type { CombatantPF2e, EncounterPF2e } from "types/pf2e/module/encounter"
+import MODULE from "src/index"
+import { BaseModule } from "../base"
+import { combatantIsNext, isJQuery, sleep } from "src/utils"
import type { TokenDocumentPF2e } from "types/pf2e/module/scene"
-import { MODULE_ID } from "./constants"
-import module from "./index"
-import { combatantIsNext, isJQuery, sleep } from "./utils"
+import { MODULE_ID } from "src/constants"
+import type { ActorPF2e } from "types/pf2e/module/actor"
+import { onRenderCombatTracker } from "./tracker"
+import { isDelaying, setInitiativeFromDrop } from "./utils"
+import { onRenderPF2eHudTracker } from "./pf2e-hud"
-async function applyDelayEffect(actor: ActorPF2e) {
- return actor.createEmbeddedDocuments("Item", [
- {
- type: "effect",
- name: "Delay",
- img: "icons/svg/clockwork.svg",
- system: {
- tokenIcon: { show: true },
- duration: {
- value: -1,
- unit: "encounter",
- sustained: false,
- expiry: "turn-start",
- },
- slug: "x-delay",
- },
- },
- ])
-}
+export class DelayModule extends BaseModule {
+ settingsKey = null
-function isDelaying(actor: ActorPF2e) {
- return actor.items.some((e) => e.slug === "x-delay")
-}
+ enable() {
+ super.enable()
-function removeDelaying(actor: ActorPF2e) {
- const e = actor.items.find((e) => e.slug === "x-delay")
- if (e?.id) return actor.deleteEmbeddedDocuments("Item", [e.id])
+ this.registerHook("renderEncounterTrackerPF2e", onRenderCombatTracker)
+ this.registerHook("renderTokenHUD", onRenderTokenHUD)
+ this.registerHook("renderPF2eHudTracker", onRenderPF2eHudTracker)
+ this.registerHook("updateCombat", onUpdateCombat)
+ this.registerHook("createChatMessage", onCreateMessage)
+
+ this.registerSocket("moveAfter", socketMoveAfter)
+ }
}
+function onRenderTokenHUD(app: TokenHUD, html: JQuery) {
+ if (MODULE.settings.showInTokenHUD) {
+ const token = app.object
+ const combatant = token?.combatant as CombatantPF2e
+ if (
+ combatant?.parent?.started &&
+ !!combatant &&
+ combatant.initiative != null &&
+ combatant.actor &&
+ combatant.isOwner
+ ) {
+ const column = html.find("div.col.right")
-const sortedCombatants = () => {
- if (!game.combat) throw new Error("No combat?")
- return game
- .combat!.combatants.filter((e) => e.initiative !== null)
- .sort((a, b) => {
- const resolveTie = (): number => {
- const [priorityA, priorityB] = [a, b].map(
- (c): number =>
- c.overridePriority(c.initiative ?? 0) ?? c.actor?.initiative?.tiebreakPriority ?? 3,
- )
- return priorityA === priorityB ? a.id.localeCompare(b.id) : priorityA - priorityB
+ if (isDelaying(combatant.actor)) {
+ if (!combatantIsNext(combatant) && MODULE.settings.allowReturn) {
+ $(`
+
+
+
`)
+ .on("click", (e) => {
+ if (combatant.actor && isDelaying(combatant.actor) && !combatantIsNext(combatant)) {
+ tryReturn(combatant)
+ e.currentTarget.style.display = "none"
+ }
+ })
+ .appendTo(column)
+ }
+ } else if (combatant.parent.combatant?.id === combatant.id) {
+ $(`
+
+
+
`)
+ .on("click", (e) => {
+ if (combatant.parent?.combatant?.id === combatant.id) tryDelay()
+ })
+ .appendTo(column)
}
+ }
+ }
- return typeof a.initiative === "number" &&
- typeof b.initiative === "number" &&
- a.initiative === b.initiative
- ? resolveTie()
- : b.initiative! - a.initiative! || (a.id > b.id ? 1 : -1)
- })
+ if (MODULE.settings.removeCombatToggle) {
+ const token = app.object
+ const combatant = token?.combatant
+ if (combatant?.parent.started && !!combatant && combatant.initiative != null) {
+ const toggleCombatButton = html.find("div.control-icon[data-action=combat]")
+ toggleCombatButton?.hide()
+ }
+ }
+}
+function onUpdateCombat(combat: EncounterPF2e) {
+ if (game.user && game.user.id !== game.users?.activeGM?.id) return
+ if (!combat.combatant?.actor) return
+ removeDelayEffect(combat.combatant.actor)
+}
+function onCreateMessage(msg: ChatMessagePF2e) {
+ if (msg?.author?.id !== game.user?.id) return
+ if (!game.combat?.started) return
+ const item = msg?.item
+ if (item?.actor?.isOwner && item?.type === "action" && item.slug === "delay")
+ if (isDelaying(item.actor) && item.actor.combatant) {
+ if (MODULE.settings.allowReturn) tryReturn(item.actor.combatant, { skipMessage: true })
+ } else if (item.actor.id === game.combat.combatant?.actorId) tryDelay({ skipMessage: true })
+}
+
+function socketMoveAfter({
+ combatId,
+ combatantId,
+ afterId,
+}: {
+ combatId: string
+ combatantId: string
+ afterId: string
+}) {
+ if (game.users.activeGM?.id !== game.user.id) return
+
+ const combat = game.combats?.get(combatId) as EncounterPF2e
+ if (!combat || combat.id !== ui.combat.viewed?.id) return
+ const combatant = combat.combatants.get(combatantId) as RolledCombatant
+ const after = combat.combatants.get(afterId)
+
+ if (!combatant || !after || combatant.initiative == null || after.initiative == null) return
+
+ const newOrder = combat.turns.filter(
+ (c): c is RolledCombatant =>
+ typeof c.initiative === "number" && c.id !== combatant.id,
+ )
+ const afterIndex = newOrder.findIndex((c) => c.id === afterId)
+ newOrder.splice(afterIndex + 1, 0, combatant)
+
+ setInitiativeFromDrop(combat, newOrder, combatant)
+
+ combat.setMultipleInitiatives(
+ newOrder.map((c) => ({
+ id: c.id,
+ value: c.initiative,
+ overridePriority: c.overridePriority(c.initiative),
+ })),
+ )
}
interface TryDelayOptions {
@@ -67,7 +136,7 @@ export function tryDelay(opts?: TryDelayOptions) {
if (!c) return ui.notifications.error("No combatant")
if (!c.token?.isOwner) return ui.notifications.error("You do not own the current combatant")
- const combatants = sortedCombatants()
+ const combatants = combat.turns
const currentId = combatants.findIndex((e) => e.id === c.id)
const options = combatants
@@ -82,7 +151,7 @@ export function tryDelay(opts?: TryDelayOptions) {
return ``
})
- if (!module.settings.delayShouldPrompt) {
+ if (!MODULE.settings.delayShouldPrompt) {
if (!opts?.skipMessage) createMessage(c.token, "Delay")
if (c.actor) applyDelayEffect(c.actor)
combat.nextTurn()
@@ -134,110 +203,40 @@ export function tryDelay(opts?: TryDelayOptions) {
).render(true)
}
-function tryReturn(combatant: CombatantPF2e, opts?: TryDelayOptions) {
+export function tryReturn(combatant: CombatantPF2e, opts?: TryDelayOptions) {
if (game.combat?.combatant && !combatantIsNext(combatant)) {
if (!opts?.skipMessage && combatant.token) createMessage(combatant.token, "Return")
emitMoveAfter(game.combat.id, combatant.id, game.combat.combatant.id)
}
}
-interface MoveAfterPayload {
- combatId: string
- combatantId: string
- afterId: string
-}
-
-function emitMoveAfter(combatId: string, combatantId: string, afterId: string) {
- module.socketHandler.emit("moveAfter", { combatId, combatantId, afterId })
+async function applyDelayEffect(actor: ActorPF2e) {
+ return actor.createEmbeddedDocuments("Item", [
+ {
+ type: "effect",
+ name: "Delay",
+ img: "icons/svg/clockwork.svg",
+ system: {
+ tokenIcon: { show: true },
+ duration: {
+ value: -1,
+ unit: "encounter",
+ sustained: false,
+ expiry: "turn-start",
+ },
+ slug: "x-delay",
+ },
+ },
+ ])
}
-export function moveAfter({ combatId, combatantId, afterId }: MoveAfterPayload) {
- if (game.users.activeGM?.id !== game.user.id) return
-
- const combat = game.combats?.get(combatId) as EncounterPF2e
- if (!combat) return
- const combatant = combat.combatants.get(combatantId)
- const after = combat.combatants.get(afterId)
-
- if (!combatant || !after) return
-
- const targetInitiative = after.initiative
- if (!targetInitiative) return
-
- const order = combat.turns
- .filter((c) => c.id !== combatantId)
- .map((c) => {
- return {
- id: c.id,
- initiative: c.initiative,
- }
- })
- let afterIndex = combat.turns.findIndex((c) => c.id === afterId)
- if (afterIndex === 0) afterIndex++
-
- const newOrder = [
- ...order.slice(0, afterIndex),
- { id: combatant.id, initiative: targetInitiative },
- ...order.slice(afterIndex),
- ]
-
- const updates: {
- id: string
- value: number
- overridePriority: number
- }[] = []
-
- const withSameInitiative = newOrder.filter((c) => c.initiative === targetInitiative)
- for (const [i, { id }] of withSameInitiative.entries()) {
- updates.push({
- id: id!,
- value: targetInitiative!,
- overridePriority: i,
- })
- }
-
- /*
- console.log("--Updates--")
- updates.forEach((e) => {
- const c = game.combat?.combatants.get(e.id)
- console.log(
- `${c?.name} ${c?.initiative} ${c?.flags.pf2e.overridePriority[c?.initiative] ?? "-"} -> ${e.value} ${
- e.overridePriority
- }`
- )
- })
- */
- game.combat?.setMultipleInitiatives(updates).catch((e) => {
- throw e
- })
+function removeDelayEffect(actor: ActorPF2e) {
+ const e = actor.items.find((e) => e.slug === "x-delay")
+ if (e?.id) return actor.deleteEmbeddedDocuments("Item", [e.id])
}
-function drawButton(type: "delay" | "return", combatentHtml: JQuery, combatant: CombatantPF2e) {
- let button = $(`
-
-
-
- `)
- if (type === "return") {
- const title = module.settings.allowReturn ? "Return to initiative" : "Delaying"
- const cls = module.settings.allowReturn ? "initiative-return" : "initiative-delay-indicator"
- button = $(`
-
-
-
-
- `)
- }
-
- const div = combatentHtml.find(".token-initiative")
- div.find(".initiative").hide()
- div.append(button)
-
- button.on("click", (e) => {
- e.stopPropagation()
- if (type === "delay") tryDelay()
- else if (module.settings.allowReturn) tryReturn(combatant)
- })
+function emitMoveAfter(combatId: string, combatantId: string, afterId: string) {
+ MODULE.socketHandler.emit("moveAfter", { combatId, combatantId, afterId })
}
function createMessage(token: TokenDocumentPF2e, title: string) {
@@ -251,92 +250,3 @@ function createMessage(token: TokenDocumentPF2e, title: string) {
`,
})
}
-
-function onRenderCombatTracker(tracker, html: JQuery, data) {
- if (!module.settings.showInCombatTracker) return
- const combat = game.combat
- if (!combat || !combat.started) return
-
- html.find(".combatant.actor").each((i, e) => {
- const id = e.dataset.combatantId
- if (!id) return
- const c = combat.combatants.get(id)
- if (!c || !c.isOwner || c.initiative == null) return
-
- if (combat.combatant?.id === c.id) drawButton("delay", $(e), c)
- else if (c.actor && isDelaying(c.actor)) drawButton("return", $(e), c)
- })
-}
-
-function onRenderTokenHUD(app: TokenHUD, html: JQuery) {
- if (module.settings.showInTokenHUD) {
- const token = app.object
- const combatant = token?.combatant as CombatantPF2e
- if (
- combatant?.parent?.started &&
- !!combatant &&
- combatant.initiative != null &&
- combatant.actor &&
- combatant.isOwner
- ) {
- const column = html.find("div.col.right")
-
- if (isDelaying(combatant.actor)) {
- if (!combatantIsNext(combatant) && module.settings.allowReturn) {
- $(`
-
-
-
`)
- .on("click", (e) => {
- if (combatant.actor && isDelaying(combatant.actor) && !combatantIsNext(combatant)) {
- tryReturn(combatant)
- e.currentTarget.style.display = "none"
- }
- })
- .appendTo(column)
- }
- } else if (combatant.parent.combatant?.id === combatant.id) {
- $(`
-
-
-
`)
- .on("click", (e) => {
- if (combatant.parent?.combatant?.id === combatant.id) tryDelay()
- })
- .appendTo(column)
- }
- }
- }
-
- if (module.settings.removeCombatToggle) {
- const token = app.object
- const combatant = token?.combatant
- if (combatant?.parent.started && !!combatant && combatant.initiative != null) {
- const toggleCombatButton = html.find("div.control-icon[data-action=combat]")
- toggleCombatButton?.hide()
- }
- }
-}
-
-export function setupDelay() {
- Hooks.on("renderEncounterTrackerPF2e", onRenderCombatTracker)
- Hooks.on("renderTokenHUD", onRenderTokenHUD)
-
- Hooks.on<[EncounterPF2e]>("updateCombat", (combat) => {
- if (game.user && game.user.id !== game.users?.activeGM?.id) return
- if (!combat.combatant?.actor) return
- removeDelaying(combat.combatant.actor)
- })
-
- Hooks.on<[ChatMessagePF2e]>("createChatMessage", (msg) => {
- if (msg?.author?.id !== game.user?.id) return
- if (!game.combat?.started) return
- const item = msg?.item
- if (item?.actor?.isOwner && item?.type === "action" && item.slug === "delay")
- if (isDelaying(item.actor) && item.actor.combatant) {
- if (module.settings.allowReturn) tryReturn(item.actor.combatant, { skipMessage: true })
- } else if (item.actor.id === game.combat.combatant?.actorId) tryDelay({ skipMessage: true })
- })
-
- module.socketHandler.register("moveAfter", moveAfter)
-}
diff --git a/src/modules/delay/pf2e-hud.ts b/src/modules/delay/pf2e-hud.ts
new file mode 100644
index 0000000..3259565
--- /dev/null
+++ b/src/modules/delay/pf2e-hud.ts
@@ -0,0 +1,70 @@
+import MODULE from "src"
+import type { EncounterPF2e } from "types/pf2e/module/encounter"
+import { isDelaying } from "./utils"
+import { tryDelay } from "."
+
+export function onRenderPF2eHudTracker(app, tracker: HTMLElement) {
+ if (!MODULE.settings.modifyPF2eHud) return
+
+ const combat = app.combat as EncounterPF2e
+ if (!combat?.started) return
+
+ const t = tracker.querySelectorAll("ol.combatants li.combatant")
+
+ for (const el of tracker.querySelectorAll("ol.combatants li.combatant")) {
+ const id = el.dataset.combatantId
+ if (!id) return
+ const c = combat.combatants.get(id)
+ if (!c || !c.isOwner || c.initiative == null) return
+
+ let delayElement: HTMLElement | null = null
+ if (game.user.isGM) delayElement = el.querySelector("a.delay")
+ else
+ delayElement =
+ el.querySelector("i.fa-solid.fa-hourglass-start") ??
+ el.querySelector("i.fa-solid.fa-dice-d20")
+
+ if (!delayElement) return
+
+ if (combat.combatant === c) {
+ delayElement.replaceWith(makeDelayButton())
+ } else if (c.actor && isDelaying(c.actor)) {
+ delayElement.replaceWith(makeReturnButton())
+ } else {
+ const icon = document.createElement("i")
+ icon.classList.add("fa-solid", "fa-dice-d20")
+ delayElement.replaceWith(icon)
+ }
+ }
+}
+
+function parseHTML(html: string) {
+ return new DOMParser().parseFromString(html, "text/html").body.firstChild!
+}
+
+function makeDelayButton() {
+ const node = parseHTML(`
+
+
+
+ `)
+ node?.addEventListener("click", (e) => {
+ e.stopPropagation()
+ tryDelay()
+ })
+ return node
+}
+
+function makeReturnButton() {
+ if (!MODULE.settings.allowReturn) {
+ return parseHTML(``)
+ }
+
+ const node = parseHTML(`
+
+
+
+ `)
+
+ return node
+}
diff --git a/src/modules/delay/tracker.ts b/src/modules/delay/tracker.ts
new file mode 100644
index 0000000..b66119d
--- /dev/null
+++ b/src/modules/delay/tracker.ts
@@ -0,0 +1,48 @@
+import type { CombatantPF2e } from "types/pf2e/module/encounter"
+import MODULE from "src"
+import { isDelaying } from "./utils"
+import { tryDelay, tryReturn } from "."
+
+export function onRenderCombatTracker(tracker, html: JQuery, data) {
+ if (!MODULE.settings.showInCombatTracker) return
+ const combat = game.combat
+ if (!combat || !combat.started) return
+
+ html.find(".combatant.actor").each((i, e) => {
+ const id = e.dataset.combatantId
+ if (!id) return
+ const c = combat.combatants.get(id)
+ if (!c || !c.isOwner || c.initiative == null) return
+
+ if (combat.combatant?.id === c.id) drawButton("delay", $(e), c)
+ else if (c.actor && isDelaying(c.actor)) drawButton("return", $(e), c)
+ })
+}
+
+function drawButton(type: "delay" | "return", combatentHtml: JQuery, combatant: CombatantPF2e) {
+ let button = $(`
+
+
+
+ `)
+ if (type === "return") {
+ const title = MODULE.settings.allowReturn ? "Return to initiative" : "Delaying"
+ const cls = MODULE.settings.allowReturn ? "initiative-return" : "initiative-delay-indicator"
+ button = $(`
+
+
+
+
+ `)
+ }
+
+ const div = combatentHtml.find(".token-initiative")
+ div.find(".initiative").hide()
+ div.append(button)
+
+ button.on("click", (e) => {
+ e.stopPropagation()
+ if (type === "delay") tryDelay()
+ else if (MODULE.settings.allowReturn) tryReturn(combatant)
+ })
+}
diff --git a/src/modules/delay/utils.ts b/src/modules/delay/utils.ts
new file mode 100644
index 0000000..c2f16eb
--- /dev/null
+++ b/src/modules/delay/utils.ts
@@ -0,0 +1,44 @@
+import type { ActorPF2e } from "types/pf2e/module/actor"
+import type { EncounterPF2e, RolledCombatant } from "types/pf2e/module/encounter"
+
+export function isDelaying(actor: ActorPF2e) {
+ return actor.items.some((e) => e.slug === "x-delay")
+}
+
+// https://github.com/foundryvtt/pf2e/blob/8ea3864e0394e944e6ccf5b65a8e31489c68505e/src/module/apps/sidebar/encounter-tracker.ts#L318
+export function setInitiativeFromDrop(
+ combat: EncounterPF2e,
+ newOrder: RolledCombatant>[],
+ dropped: RolledCombatant>,
+): void {
+ const aboveDropped = newOrder.find((c) => newOrder.indexOf(c) === newOrder.indexOf(dropped) - 1)
+ const belowDropped = newOrder.find((c) => newOrder.indexOf(c) === newOrder.indexOf(dropped) + 1)
+
+ const hasAboveAndBelow = !!aboveDropped && !!belowDropped
+ const hasAboveAndNoBelow = !!aboveDropped && !belowDropped
+ const hasBelowAndNoAbove = !aboveDropped && !!belowDropped
+ const aboveIsHigherThanBelow =
+ hasAboveAndBelow && belowDropped.initiative < aboveDropped.initiative
+ const belowIsHigherThanAbove =
+ hasAboveAndBelow && belowDropped.initiative < aboveDropped.initiative
+ const wasDraggedUp =
+ !!belowDropped && combat.getCombatantWithHigherInit(dropped, belowDropped) === belowDropped
+ const wasDraggedDown = !!aboveDropped && !wasDraggedUp
+
+ // Set a new initiative intuitively, according to allegedly commonplace intuitions
+ dropped.initiative =
+ hasBelowAndNoAbove || (aboveIsHigherThanBelow && wasDraggedUp)
+ ? belowDropped.initiative + 1
+ : hasAboveAndNoBelow || (belowIsHigherThanAbove && wasDraggedDown)
+ ? aboveDropped.initiative - 1
+ : hasAboveAndBelow
+ ? belowDropped.initiative
+ : dropped.initiative
+
+ const withSameInitiative = newOrder.filter((c) => c.initiative === dropped.initiative)
+ if (withSameInitiative.length > 1) {
+ for (let priority = 0; priority < withSameInitiative.length; priority++) {
+ withSameInitiative[priority].flags.pf2e.overridePriority[dropped.initiative] = priority
+ }
+ }
+}
diff --git a/src/emanation-dialog.ts b/src/modules/emanation/emanation-dialog.ts
similarity index 98%
rename from src/emanation-dialog.ts
rename to src/modules/emanation/emanation-dialog.ts
index ac045f9..7992eb7 100644
--- a/src/emanation-dialog.ts
+++ b/src/modules/emanation/emanation-dialog.ts
@@ -1,8 +1,7 @@
import type { EffectPF2e, SpellPF2e } from "types/pf2e/module/item"
import type { TokenDocumentPF2e } from "types/pf2e/module/scene"
-import { MODULE_ID } from "./constants"
+import { MODULE_ID } from "src/constants"
import { type EmanationRequestData, extractFlagData } from "./emanation"
-import Module from "./index"
export class EmanationRequestDialog extends Application {
#request: EmanationRequestData
diff --git a/src/emanation.ts b/src/modules/emanation/emanation.ts
similarity index 87%
rename from src/emanation.ts
rename to src/modules/emanation/emanation.ts
index 0fab5d4..6ff49c7 100644
--- a/src/emanation.ts
+++ b/src/modules/emanation/emanation.ts
@@ -1,10 +1,36 @@
import type { ChatMessagePF2e } from "types/pf2e/module/chat-message"
import type { ItemPF2e } from "types/pf2e/module/item"
import type { SpellPF2e, SpellSheetPF2e } from "types/pf2e/module/item/spell"
-import { MODULE_ID } from "./constants"
+import { MODULE_ID } from "src/constants"
import { EmanationRequestDialog } from "./emanation-dialog"
-import Module from "./index"
-import module from "./index"
+import MODULE from "src/index"
+import { BaseModule } from "../base"
+
+export class EmanationModule extends BaseModule {
+ settingsKey = "emanation-automation"
+
+ enable(): void {
+ if (!game.modules.get("lib-wrapper")?.active) return
+
+ super.enable()
+
+ this.registerHook("renderChatMessage", onChatMessage)
+ }
+
+ onReady(): void {
+ this.registerWrapper(
+ 'CONFIG.Item.sheetClasses.spell["pf2e.SpellSheetPF2e"].cls.prototype._renderInner',
+ spellSheetRenderWrapper,
+ "WRAPPER",
+ )
+
+ this.registerWrapper(
+ 'CONFIG.Item.sheetClasses.spell["pf2e.SpellSheetPF2e"].cls.prototype.activateListeners',
+ spellSheetActivateListenersWrapper,
+ "WRAPPER",
+ )
+ }
+}
export interface EmanationRequestData {
/** Spell UUID */
@@ -56,7 +82,7 @@ async function extractEffects(item: SpellPF2e) {
}
async function onChatMessage(msg: ChatMessagePF2e, html: JQuery<"div">) {
- if (!module.settings.emanationAutomation) return
+ if (!MODULE.settings.emanationAutomation) return
if (!game.user.isGM) return
if (!msg.item || !msg.item.isOfType("spell")) return
const spell = msg.item
@@ -136,7 +162,7 @@ async function spellSheetRenderInner(sheet: SpellSheetPF2e, $html: JQuery) {
async function spellSheetRenderWrapper(this: SpellSheetPF2e, wrapped, ...args) {
const $html = await wrapped(...args)
- if (module.settings.emanationAutomation) await spellSheetRenderInner(this, $html)
+ if (MODULE.settings.emanationAutomation) await spellSheetRenderInner(this, $html)
return $html
}
@@ -163,24 +189,3 @@ function spellSheetActivateListenersWrapper(this: SpellSheetPF2e, wrapped, $html
return $html
}
-
-export function setupEmanationAutomation() {
- if (!module.settings.emanationAutomation) return
-
- Hooks.on("renderChatMessage", onChatMessage)
- // Hooks.on("renderSpellSheetPF2e", onSheetRender)
-
- libWrapper.register(
- MODULE_ID,
- 'CONFIG.Item.sheetClasses.spell["pf2e.SpellSheetPF2e"].cls.prototype._renderInner',
- spellSheetRenderWrapper,
- "WRAPPER",
- )
-
- libWrapper.register(
- MODULE_ID,
- 'CONFIG.Item.sheetClasses.spell["pf2e.SpellSheetPF2e"].cls.prototype.activateListeners',
- spellSheetActivateListenersWrapper,
- "WRAPPER",
- )
-}
diff --git a/src/modules/flat/flat.ts b/src/modules/flat/flat.ts
new file mode 100644
index 0000000..641eaad
--- /dev/null
+++ b/src/modules/flat/flat.ts
@@ -0,0 +1,118 @@
+import type { TokenPF2e } from "types/pf2e/module/canvas"
+import MODULE from "src/index"
+import { MoreDialog } from "./more-dialog"
+import { BaseModule } from "../base"
+
+export class FlatModule extends BaseModule {
+ settingsKey = "show-global"
+
+ enable() {
+ super.enable()
+
+ this.registerHook("renderSidebarTab", onRenderSidebarTab)
+ this.registerHook("targetToken", onTargetToken)
+ }
+ disable() {
+ super.disable()
+ }
+}
+
+export async function rollFlatCheck(
+ dc: number,
+ { hidden = false, label }: { hidden: boolean; label?: string },
+) {
+ const r = await new Roll("d20").roll()
+ const degree = r.total >= dc ? "success" : "failure"
+ const delta = r.total - dc
+ const deltaText = delta > 0 ? `+${delta}` : delta.toString()
+
+ const flavor = document.createElement("span")
+ flavor.classList.add("flavor-text")
+ flavor.innerHTML = `
+
+
${label ?? "Flat"} DC: ${dc}
+
+ Result: ${degree.capitalize()}
+ by ${deltaText}
+
+
+ `
+
+ r.toMessage({ flavor: flavor.outerHTML }, { rollMode: hidden ? "blindroll" : "roll" })
+}
+
+export const CONDITION_DCS = {
+ concealed: 5,
+ hidden: 11,
+ invisible: 11,
+}
+
+function dcForToken(token: TokenPF2e) {
+ let dc = 0
+ token.actor?.conditions.stored.forEach((c) => {
+ dc = Math.max(CONDITION_DCS[c.slug] ?? 0, dc)
+ })
+ return dc || null
+}
+
+async function rollForSingleTarget(
+ target: TokenPF2e | undefined,
+ { hidden = false }: { hidden: boolean },
+) {
+ if (!target) return
+ const dc = dcForToken(target)
+ if (!dc) ui.notifications.warn("Selected target has no conditions that require a flat check")
+ else rollFlatCheck(dc, { hidden })
+}
+
+async function onRenderSidebarTab(app: SidebarTab, html: HTMLCollection) {
+ if (app.tabName !== "chat") return
+ if (!MODULE.settings.fcButtonsEnabled) return
+
+ const chat = html[0].querySelector("#chat-form")
+
+ const template = await renderTemplate("modules/pf2e-flatcheck-helper/templates/buttons.hbs", {})
+ const node = document.createElement("div")
+ node.id = "fc-container"
+ node.innerHTML = template
+
+ chat?.after(node)
+ const $node = $(node)
+ // @ts-expect-error
+ $node.find(".tooltip").tooltipster({
+ contentAsHTML: true,
+ })
+
+ node.querySelectorAll("button").forEach((button) =>
+ button.addEventListener("click", function (e) {
+ const value = this.dataset.dc
+ if (!value) throw new Error(`Bad button DC value ${value}`)
+ const hidden = e.ctrlKey
+
+ if (value === "targets") {
+ if (game.user?.targets.size === 0) return ui.notifications.warn("No targets selected")
+ if (game.user?.targets.size === 1)
+ return rollForSingleTarget(game.user.targets.first(), { hidden })
+ else return ui.notifications.warn("Too many targets")
+ } else if (value === "more") {
+ new MoreDialog().render(true)
+ } else {
+ const dc = Number(value)
+ if (Number.isNaN(dc)) throw new Error(`Bad button DC value ${value}`)
+
+ rollFlatCheck(dc, { hidden })
+ }
+ }),
+ )
+}
+
+function onTargetToken(user: User) {
+ if (user.id !== game.user?.id) return
+ if (game.user.targets.size !== 1)
+ return document.querySelector("#fc-button-target")?.classList.remove("highlight")
+ const effectSlugs = Object.keys(CONDITION_DCS)
+
+ if (game.user?.targets?.first()?.actor?.conditions.some((c) => effectSlugs.includes(c.slug)))
+ document.querySelector("#fc-button-target")?.classList.add("highlight")
+ else document.querySelector("#fc-button-target")?.classList.remove("highlight")
+}
diff --git a/src/more-dialog.ts b/src/modules/flat/more-dialog.ts
similarity index 100%
rename from src/more-dialog.ts
rename to src/modules/flat/more-dialog.ts
diff --git a/src/modules/life-link.ts b/src/modules/life-link.ts
new file mode 100644
index 0000000..1a74725
--- /dev/null
+++ b/src/modules/life-link.ts
@@ -0,0 +1,326 @@
+import type { ActorPF2e } from "types/pf2e/module/actor"
+import type { ChatMessagePF2e } from "types/pf2e/module/chat-message"
+import type { CombatantPF2e, EncounterPF2e } from "types/pf2e/module/encounter"
+import type { EffectPF2e, ItemPF2e } from "types/pf2e/module/item"
+import MODULE from "src/index"
+import { actorEffectBySlug, actorHasEffect } from "src/utils"
+import { BaseModule } from "./base"
+
+export class LifeLinkModule extends BaseModule {
+ settingsKey = "lifelink"
+
+ enable() {
+ super.enable()
+
+ this.registerHook("pf2e.startTurn", onStartTurn)
+ this.registerHook("createItem", onCreateItem)
+ this.registerHook("createChatMessage", onCreateMessage)
+ this.registerHook("renderChatMessage", onRenderChatMessage)
+ }
+}
+
+interface ButtonArgs {
+ // HP to transfer from source to target
+ // Stops if source doesn't have enough HP
+ transfer?: number
+ // HP to restore to target
+ heal?: number
+ // HP to remove from source
+ dmg?: number
+ source: string
+ target: string
+ cd?: 1
+}
+
+const UNDO_BUTTON_MARKUP = ``
+
+function makeButton(label: string, args: ButtonArgs) {
+ const json = JSON.stringify(args)
+ return `
+
+
+ ${label}
+
+ `
+}
+
+async function updateHP(actor, delta) {
+ const hp = actor.system.attributes.hp.value
+ await actor.update({
+ "system.attributes.hp.value": hp + delta,
+ })
+}
+
+async function handleTransferButton(args: ButtonArgs) {
+ const required = ["source", "target"]
+ for (const k of required) {
+ if (!(k in args)) return ui.notifications.error(`Missing arg ${k}`)
+ }
+
+ const source = fromUuidSync(args.source)
+ const target = fromUuidSync(args.target)
+ if (!target) return ui.notifications.error("No target actor")
+ if (!source) return ui.notifications.error("No source actor")
+ if (source.id === target.id) return ui.notifications.error("Can't transfer damage to self!")
+
+ let transfer = 0
+
+ if (args.transfer) {
+ const missingHP =
+ // @ts-expect-error pf2e
+ target.system.attributes.hp.max - target.system.attributes.hp.value
+ const maxTransfer = Math.min(Number(args.transfer), missingHP)
+
+ // @ts-expect-error pf2e
+ const hpRemaining = source.system.attributes.hp.value
+ transfer = Math.min(maxTransfer, hpRemaining)
+
+ if (transfer <= 0) return ui.notifications.warn("No HP remaining to transfer.")
+ }
+
+ let heal = transfer
+ let dmg = transfer
+
+ // If the HP transfer (from life link) reduces the source to 0, share life no longer applies
+ // @ts-expect-error pf2e
+ if (transfer < source.system.attributes.hp.value) {
+ heal += args.heal ?? 0
+ dmg += args.dmg ?? 0
+ }
+ await updateHP(source, -dmg)
+ await updateHP(target, heal)
+
+ if (args.cd) {
+ await target.createEmbeddedDocuments("Item", [
+ {
+ type: "effect",
+ name: "Life Link CD",
+ img: "systems/pf2e/icons/spells/life-link.webp",
+ system: {
+ tokenIcon: { show: true },
+ duration: {
+ value: 1,
+ unit: "rounds",
+ sustained: false,
+ expiry: "turn-start",
+ },
+ slug: "life-link-cd",
+ },
+ },
+ ])
+ }
+
+ // @ts-expect-error Using uuids as keys doesn't work, but this does. Only question is when does this break
+ await ChatMessage.create({
+ content: `
+ ${target.name} +${heal} HP
+ 🡰
+ ${source.name} -${dmg} HP
+
+ ${UNDO_BUTTON_MARKUP}
+ `,
+ flags: {
+ undo: [
+ [source.uuid, dmg],
+ [target.uuid, -heal],
+ ],
+ },
+ })
+}
+
+function handleSpiritLink(effect: EffectPF2e) {
+ const { actor, origin } = effect
+
+ if (!actor) {
+ return null
+ }
+
+ if (!origin || origin.id === actor.id) {
+ ui.notifications.error(
+ `Bad origin actor for Spirit Linked effect on ${actor.name}! See module readme.`,
+ )
+ return null
+ }
+
+ const transfer = effect.level * 2
+ const missingHP = actor.system.attributes.hp!.max - actor.system.attributes.hp!.value
+ if (missingHP <= 0) return null
+
+ return makeButton(`${transfer} HP to ${actor.name}`, {
+ transfer,
+ source: origin.uuid,
+ target: actor.uuid,
+ })
+}
+
+async function onStartTurn(combatant: CombatantPF2e) {
+ if (game?.users?.activeGM?.id !== game.user?.id) return
+
+ const links: string[] = []
+
+ canvas.scene?.tokens.forEach(({ actor }) => {
+ if (!actor) return
+ const e = actorEffectBySlug(actor, "spirit-linked")
+ if (!e) return
+ if (combatant.actor?.id !== e.origin?.id) return
+
+ const link = handleSpiritLink(e)
+ if (link) links.push(link)
+ })
+
+ const content = `Spirit Link
${links.join("
")}`
+
+ if (links.length) {
+ await ChatMessage.create({
+ content: content,
+ whisper: ChatMessage.getWhisperRecipients("GM").map((u) => u.id),
+ speaker: ChatMessage.getSpeaker({ actor: combatant.actor }),
+ })
+ }
+}
+
+async function onCreateItem(item: ItemPF2e) {
+ if (item.isOfType("effect") && item.slug === "spirit-linked") {
+ const link = handleSpiritLink(item)
+ if (link) {
+ await ChatMessage.create({
+ content: `Spirit Link
${link}`,
+ whisper: ChatMessage.getWhisperRecipients("GM").map((u) => u.id),
+ speaker: ChatMessage.getSpeaker({ actor: item.actor }),
+ })
+ }
+ }
+}
+
+async function onCreateMessage(msg: ChatMessagePF2e) {
+ if (game.users?.activeGM?.id !== game.user?.id) return
+
+ const flags = msg.flags?.pf2e?.appliedDamage
+ const uuid = flags?.uuid
+ const dmg = flags?.updates.find((e) => e.path === "system.attributes.hp.value")?.value
+ if (!uuid || !dmg || dmg <= 0) return
+
+ const actor = fromUuidSync(uuid) as ActorPF2e
+ if (!actor) return
+
+ let lifeLinkTransfer = 0
+
+ const lifeLinkEffect = actorEffectBySlug(actor, "life-linked")
+ if (lifeLinkEffect && !actorHasEffect(actor, "life-link-cd")) {
+ lifeLinkTransfer = (() => {
+ if (!lifeLinkEffect.origin || lifeLinkEffect.origin.id === actor.id) {
+ ui.notifications.error(
+ `Bad origin actor for Life Linked effect on ${actor.name}! See module readme.`,
+ {
+ permanent: true,
+ },
+ )
+ return 0
+ }
+
+ let maxTransfer = 3
+ if (MODULE.settings.lifeLinkVariant === "plus")
+ maxTransfer = 2 + Math.floor((lifeLinkEffect.level - 1) / 2) * 3
+ else {
+ if (lifeLinkEffect.level >= 3) maxTransfer = 5
+ if (lifeLinkEffect.level >= 6) maxTransfer = 10
+ if (lifeLinkEffect.level >= 9) maxTransfer = 15
+ }
+
+ return Math.min(maxTransfer, dmg)
+ })()
+ }
+
+ const shareLifeEffect = actorEffectBySlug(actor, "share-life")
+
+ if (shareLifeEffect && !shareLifeEffect?.origin)
+ ui.notifications.error(
+ `Bad origin actor for Share Life effect on ${actor.name}! See module readme.`,
+ {
+ permanent: true,
+ },
+ )
+
+ const buttons: string[] = []
+ ;(() => {
+ if (shareLifeEffect && lifeLinkTransfer) {
+ const remainingDmg = dmg - lifeLinkTransfer
+
+ if (
+ shareLifeEffect?.origin &&
+ lifeLinkEffect?.origin &&
+ shareLifeEffect.origin.uuid === lifeLinkEffect.origin.uuid
+ ) {
+ // Both effects from the same source -> One Button
+ buttons.push(
+ makeButton(
+ `${Math.ceil(remainingDmg / 2) + lifeLinkTransfer} to ${lifeLinkEffect.origin.name}`,
+ {
+ transfer: lifeLinkTransfer,
+ heal: remainingDmg === 1 ? 1 : Math.floor(remainingDmg / 2),
+ dmg: Math.ceil(remainingDmg / 2),
+ cd: 1,
+ source: lifeLinkEffect.origin.uuid,
+ target: actor.uuid,
+ },
+ ),
+ )
+ return
+ }
+ }
+ // return above means this is unreachable if both effects are from the same source
+ if (shareLifeEffect?.origin) {
+ const remainingDmg = dmg - lifeLinkTransfer
+ // Button for Share Life
+ if (remainingDmg)
+ buttons.push(
+ makeButton(
+ `(Share Life) ${Math.ceil(remainingDmg / 2)} to ${shareLifeEffect.origin.name}`,
+ {
+ heal: remainingDmg === 1 ? 1 : Math.floor(remainingDmg / 2),
+ dmg: Math.ceil(remainingDmg / 2),
+ source: shareLifeEffect.origin.uuid,
+ target: actor.uuid,
+ },
+ ),
+ )
+ }
+ if (lifeLinkEffect?.origin && lifeLinkTransfer) {
+ buttons.push(
+ makeButton(`(Life Link) ${lifeLinkTransfer} to ${lifeLinkEffect.origin.name}`, {
+ transfer: lifeLinkTransfer,
+ cd: 1,
+ source: lifeLinkEffect.origin.uuid,
+ target: actor.uuid,
+ }),
+ )
+ }
+ })()
+
+ if (buttons.length) {
+ await ChatMessage.create({
+ content: `Damage Transfer
${buttons.join("
")}`,
+ whisper: ChatMessage.getWhisperRecipients("GM").map((u) => u.id),
+ speaker: ChatMessage.getSpeaker(actor),
+ })
+ }
+}
+
+function onRenderChatMessage(msg: ChatMessagePF2e, html: JQuery) {
+ if (!game.user?.isGM) return
+ html.find("a.life-link").on("click", async (event) => {
+ const args = JSON.parse(event.target.dataset.args!) as ButtonArgs
+ await handleTransferButton(args)
+ })
+ html.find("button.fc-undo-button").on("click", async () => {
+ const data = msg.flags.undo as unknown as [string, number][]
+ for (const [uuid, dmg] of data) {
+ const actor = await fromUuid(uuid)
+ await updateHP(actor, dmg)
+ }
+ html.find(".undo-text").addClass("undo")
+ await msg.update({
+ content: html.find(".message-content").html(),
+ })
+ })
+}
diff --git a/src/settings.ts b/src/settings.ts
index 63e710f..f61e45a 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -1,5 +1,8 @@
import { MODULE_ID } from "./constants"
+type Callback = (value: unknown) => void
+const listeners: Record = {}
+
export const settings = {
get fcButtonsEnabled() {
return (game.settings.get(MODULE_ID, "show-global") &&
@@ -22,6 +25,9 @@ export const settings = {
get showInTokenHUD() {
return game.settings.get(MODULE_ID, "delay-token-hud") as boolean
},
+ get modifyPF2eHud() {
+ return game.settings.get(MODULE_ID, "pf2e-hud-enable") as boolean
+ },
get removeCombatToggle() {
return game.settings.get(MODULE_ID, "token-hud-remove-combat-toggle") as boolean
},
@@ -40,7 +46,7 @@ export const settings = {
},
init() {
- game.settings.register(MODULE_ID, "show-global", {
+ register("show-global", {
name: "Enable flat check buttons",
hint: "Global setting: Enables flat check buttons below the chat box.",
scope: "world",
@@ -49,7 +55,7 @@ export const settings = {
type: Boolean,
requiresReload: true,
})
- game.settings.register(MODULE_ID, "show", {
+ register("show", {
name: "Show flat check buttons",
hint: "Client setting: Turn off to hide the flat check buttons just for you.",
scope: "client",
@@ -59,7 +65,7 @@ export const settings = {
requiresReload: true,
})
- game.settings.register(MODULE_ID, "delay-combat-tracker", {
+ register("delay-combat-tracker", {
name: "Show delay button in combat tracker",
hint: "Adds delay/return buttons to the combat tracker. Will probably not work with any modules that change the combat tracker.",
scope: "world",
@@ -68,7 +74,7 @@ export const settings = {
type: Boolean,
})
- game.settings.register(MODULE_ID, "delay-token-hud", {
+ register("delay-token-hud", {
name: "Show delay button in token HUD",
hint: "Adds delay/return buttons to the menu that appears when right-clicking a token",
scope: "world",
@@ -77,7 +83,7 @@ export const settings = {
type: Boolean,
})
- game.settings.register(MODULE_ID, "delay-return", {
+ register("delay-return", {
name: "Enable return button",
hint: "Allows returning to initiative by pressing the delay button again.",
scope: "world",
@@ -86,7 +92,7 @@ export const settings = {
type: Boolean,
})
- game.settings.register(MODULE_ID, "delay-prompt", {
+ register("delay-prompt", {
name: "Prompt for new initiative",
hint: "Lets the user select a combatant to delay their turn after. Can still return early anytime they want.",
scope: "world",
@@ -95,7 +101,7 @@ export const settings = {
type: Boolean,
})
- game.settings.register(MODULE_ID, "delay-create-message", {
+ register("delay-create-message", {
name: "Delay/Return creates chat message",
scope: "world",
config: true,
@@ -103,7 +109,7 @@ export const settings = {
type: Boolean,
})
- game.settings.register(MODULE_ID, "token-hud-remove-combat-toggle", {
+ register("token-hud-remove-combat-toggle", {
name: "Remove combat toggle from token HUD",
hint: "Removes the 'Toggle Combat State' button for tokens in combat",
scope: "world",
@@ -112,7 +118,16 @@ export const settings = {
type: Boolean,
})
- game.settings.register(MODULE_ID, "lifelink", {
+ register("pf2e-hud-enable", {
+ name: "Modify PF2e HUD",
+ hint: "Overrides PF2e HUDs delay handling with this modules implementation. Please report issue with this to me and not to PF2e HUD!",
+ scope: "world",
+ config: true,
+ default: false,
+ type: Boolean,
+ })
+
+ register("lifelink", {
name: "Enable life/spirit link automation buttons",
hint: "Check the module readme for setup steps.",
scope: "world",
@@ -121,7 +136,7 @@ export const settings = {
type: Boolean,
})
- game.settings.register(MODULE_ID, "lifelink-formular", {
+ register("lifelink-formular", {
name: "Life Link Formular",
hint: "Variant of life link damage absorption to use",
scope: "world",
@@ -134,7 +149,7 @@ export const settings = {
},
})
- game.settings.register(MODULE_ID, "emanation-automation", {
+ register("emanation-automation", {
name: "Enable automatic emanation effect application",
hint: "Still experimental, may change it this works in the future. Requires libwrapper.",
scope: "world",
@@ -142,5 +157,31 @@ export const settings = {
type: Boolean,
default: false,
})
+
+ for (const [key, setting] of game.settings.settings) {
+ if (!key.startsWith(MODULE_ID)) continue
+ }
+ },
+
+ addListener(key: string, callback: Callback) {
+ listeners[key] = callback
+ },
+ removeListener(key: string) {
+ delete listeners[key]
},
+ callListener(key: string, value: unknown) {
+ listeners[key]?.(value)
+ },
+}
+
+type SettingRegistration = Parameters[2]
+
+function register(key: string, data: SettingRegistration) {
+ game.settings.register(MODULE_ID, key, {
+ ...data,
+ onChange() {
+ const value = game.settings.get(MODULE_ID, key)
+ settings.callListener(key, value)
+ },
+ })
}
diff --git a/src/types-modules.d.ts b/src/types-modules.d.ts
index b03291b..76f03ea 100644
--- a/src/types-modules.d.ts
+++ b/src/types-modules.d.ts
@@ -7,6 +7,7 @@ declare global {
type: "MIXED" | "WRAPPER" | "OVERRIDE",
options?: any,
)
+ unregister(moduleId: string, target: number | string)
}
const libWrapper: LibWrapper
diff --git a/src/vite-index.js b/src/vite-index.js
new file mode 100644
index 0000000..7b8986e
--- /dev/null
+++ b/src/vite-index.js
@@ -0,0 +1,2 @@
+import "./index.ts";
+import "./main.css";
diff --git a/tsconfig.json b/tsconfig.json
index 3e0073c..67ba4a1 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,13 +2,9 @@
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
- "lib": [
- "DOM",
- "ESNext"
- ],
"allowJs": true,
"outDir": "dist",
- "rootDir": "./src",
+ "rootDir": ".",
"noEmit": true,
"strict": true,
"noImplicitAny": false,
@@ -18,6 +14,7 @@
"types",
],
"types": [
+ "node",
"jquery",
"foundry",
"pf2e"
@@ -27,11 +24,16 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
+ "paths": {
+ "src": [
+ "./src/index"
+ ],
+ "src/*": [
+ "./src/*"
+ ]
+ }
},
"include": [
"./src/**/*",
],
- "files": [
- "./src/index.ts"
- ],
}
\ No newline at end of file
diff --git a/types/pf2e/index.d.ts b/types/pf2e/index.d.ts
index 99aed36..9c35746 100644
--- a/types/pf2e/index.d.ts
+++ b/types/pf2e/index.d.ts
@@ -1,332 +1,343 @@
-import type { ActorPF2e } from "types/pf2emodule/actor/index.ts"
-import type { Action } from "types/pf2emodule/actor/actions/index.ts"
-import type { AutomaticBonusProgression } from "types/pf2emodule/actor/character/automatic-bonus-progression.ts"
-import type { ElementalBlast } from "types/pf2emodule/actor/character/elemental-blast.ts"
-import type { FeatGroupOptions } from "types/pf2emodule/actor/character/feats.ts"
-import type { CheckModifier, ModifierPF2e, ModifierType, StatisticModifier } from "types/pf2emodule/actor/modifiers.ts"
-import type { ItemPF2e, PhysicalItemPF2e } from "types/pf2emodule/item/index.ts"
-import type { ConditionSource } from "types/pf2emodule/item/condition/data.ts"
-import type { CoinsPF2e } from "types/pf2emodule/item/physical/helpers.ts"
-import type { ActiveEffectPF2e } from "types/pf2emodule/active-effect.ts"
+import type { ActorPF2e } from "types/pf2e/module/actor/index.ts"
+import type { Action } from "types/pf2e/module/actor/actions/index.ts"
+import type { AutomaticBonusProgression } from "types/pf2e/module/actor/character/automatic-bonus-progression.ts"
+import type { ElementalBlast } from "types/pf2e/module/actor/character/elemental-blast.ts"
+import type { FeatGroupOptions } from "types/pf2e/module/actor/character/feats.ts"
import type {
- CompendiumBrowser,
- CompendiumBrowserSettings,
- CompendiumBrowserSources,
-} from "types/pf2emodule/apps/compendium-browser/index.ts"
-import type { EffectsPanel } from "types/pf2emodule/apps/effects-panel.ts"
-import type { HotbarPF2e } from "types/pf2emodule/apps/hotbar.ts"
-import type { LicenseViewer } from "types/pf2emodule/apps/license-viewer/app.ts"
+ CheckModifier,
+ ModifierPF2e,
+ ModifierType,
+ StatisticModifier,
+} from "types/pf2e/module/actor/modifiers.ts"
+import type { ItemPF2e, PhysicalItemPF2e } from "types/pf2e/module/item/index.ts"
+import type { ConditionSource } from "types/pf2e/module/item/condition/data.ts"
+import type { CoinsPF2e } from "types/pf2e/module/item/physical/helpers.ts"
+import type { ActiveEffectPF2e } from "types/pf2e/module/active-effect.ts"
import type {
- ActorDirectoryPF2e,
- ChatLogPF2e,
- CompendiumDirectoryPF2e,
- EncounterTrackerPF2e,
-} from "types/pf2emodule/apps/sidebar/index.ts"
-import type { WorldClock } from "types/pf2emodule/apps/world-clock/app.ts"
-import type { CanvasPF2e, EffectsCanvasGroupPF2e } from "types/pf2emodule/canvas/index.ts"
-import type { StatusEffects } from "types/pf2emodule/canvas/status-effects.ts"
-import type { ChatMessagePF2e } from "types/pf2emodule/chat-message/index.ts"
-import type { ActorsPF2e } from "types/pf2emodule/collection/actors.ts"
-import type { CombatantPF2e, EncounterPF2e } from "types/pf2emodule/encounter/index.ts"
-import type { MacroPF2e } from "types/pf2emodule/macro.ts"
-import type { RuleElementPF2e, RuleElements } from "types/pf2emodule/rules/index.ts"
-import type { UserPF2e } from "types/pf2emodule/user/index.ts"
+ CompendiumBrowser,
+ CompendiumBrowserSettings,
+ CompendiumBrowserSources,
+} from "types/pf2e/module/apps/compendium-browser/index.ts"
+import type { EffectsPanel } from "types/pf2e/module/apps/effects-panel.ts"
+import type { HotbarPF2e } from "types/pf2e/module/apps/hotbar.ts"
+import type { LicenseViewer } from "types/pf2e/module/apps/license-viewer/app.ts"
import type {
- AmbientLightDocumentPF2e,
- MeasuredTemplateDocumentPF2e,
- RegionBehaviorPF2e,
- RegionDocumentPF2e,
- ScenePF2e,
- TileDocumentPF2e,
- TokenDocumentPF2e,
-} from "types/pf2emodule/scene/index.ts"
-import type { ActorDeltaPF2e } from "types/pf2emodule/scene/token-document/actor-delta.ts"
-import type { PF2ECONFIG, StatusEffectIconTheme } from "types/pf2escripts/config/index.ts"
-import type { DicePF2e } from "types/pf2escripts/dice.ts"
+ ActorDirectoryPF2e,
+ ChatLogPF2e,
+ CompendiumDirectoryPF2e,
+ EncounterTrackerPF2e,
+} from "types/pf2e/module/apps/sidebar/index.ts"
+import type { WorldClock } from "types/pf2e/module/apps/world-clock/app.ts"
+import type { CanvasPF2e, EffectsCanvasGroupPF2e } from "types/pf2e/module/canvas/index.ts"
+import type { StatusEffects } from "types/pf2e/module/canvas/status-effects.ts"
+import type { ChatMessagePF2e } from "types/pf2e/module/chat-message/index.ts"
+import type { ActorsPF2e } from "types/pf2e/module/collection/actors.ts"
+import type { CombatantPF2e, EncounterPF2e } from "types/pf2e/module/encounter/index.ts"
+import type { MacroPF2e } from "types/pf2e/module/macro.ts"
+import type { RuleElementPF2e, RuleElements } from "types/pf2e/module/rules/index.ts"
+import type { UserPF2e } from "types/pf2e/module/user/index.ts"
import type {
- calculateXP,
- checkPrompt,
- editPersistent,
- launchTravelSheet,
- perceptionForSelected,
- rollActionMacro,
- rollItemMacro,
- stealthForSelected,
- xpFromEncounter,
-} from "types/pf2escripts/macros/index.ts"
-import type { remigrate } from "types/pf2escripts/system/remigrate.ts"
-import type { CheckPF2e } from "types/pf2emodule/system/check/index.ts"
-import type { ConditionManager } from "types/pf2emodule/system/conditions/manager.ts"
-import type { EffectTracker } from "types/pf2emodule/system/effect-tracker.ts"
-import type { ModuleArt } from "types/pf2emodule/system/module-art.ts"
-import type { Predicate } from "types/pf2emodule/system/predication.ts"
+ AmbientLightDocumentPF2e,
+ MeasuredTemplateDocumentPF2e,
+ RegionBehaviorPF2e,
+ RegionDocumentPF2e,
+ ScenePF2e,
+ TileDocumentPF2e,
+ TokenDocumentPF2e,
+} from "types/pf2e/module/scene/index.ts"
+import type { ActorDeltaPF2e } from "types/pf2e/module/scene/token-document/actor-delta.ts"
+import type { PF2ECONFIG, StatusEffectIconTheme } from "types/pf2e/scripts/config/index.ts"
+import type { DicePF2e } from "types/pf2e/scripts/dice.ts"
import type {
- CustomDamageData,
- HomebrewTag,
- HomebrewTraitSettingsKey,
- LanguageSettings,
-} from "types/pf2emodule/system/settings/homebrew/index.ts"
-import type { TextEditorPF2e } from "types/pf2emodule/system/text-editor.ts"
-import type { sluggify } from "types/pf2eutil/index.ts"
+ calculateXP,
+ checkPrompt,
+ editPersistent,
+ launchTravelSheet,
+ perceptionForSelected,
+ rollActionMacro,
+ rollItemMacro,
+ stealthForSelected,
+ xpFromEncounter,
+} from "types/pf2e/scripts/macros/index.ts"
+import type { remigrate } from "types/pf2e/scripts/system/remigrate.ts"
+import type { CheckPF2e } from "types/pf2e/module/system/check/index.ts"
+import type { ConditionManager } from "types/pf2e/module/system/conditions/manager.ts"
+import type { EffectTracker } from "types/pf2e/module/system/effect-tracker.ts"
+import type { ModuleArt } from "types/pf2e/module/system/module-art.ts"
+import type { Predicate } from "types/pf2e/module/system/predication.ts"
+import type {
+ CustomDamageData,
+ HomebrewTag,
+ HomebrewTraitSettingsKey,
+ LanguageSettings,
+} from "types/pf2e/module/system/settings/homebrew/index.ts"
+import type { TextEditorPF2e } from "types/pf2e/module/system/text-editor.ts"
+import type { sluggify } from "types/pf2e/util/index.ts"
interface GamePF2e
- extends Game<
- ActorPF2e,
- ActorsPF2e>,
- ChatMessagePF2e,
- EncounterPF2e,
- ItemPF2e,
- MacroPF2e,
- ScenePF2e,
- UserPF2e
- > {
- pf2e: {
- actions: Record & Collection
- compendiumBrowser: CompendiumBrowser
- licenseViewer: LicenseViewer
- worldClock: WorldClock
- effectPanel: EffectsPanel
- effectTracker: EffectTracker
- rollActionMacro: typeof rollActionMacro
- rollItemMacro: typeof rollItemMacro
- gm: {
- calculateXP: typeof calculateXP
- checkPrompt: typeof checkPrompt
- editPersistent: typeof editPersistent
- launchTravelSheet: typeof launchTravelSheet
- perceptionForSelected: typeof perceptionForSelected
- stealthForSelected: typeof stealthForSelected
- xpFromEncounter: typeof xpFromEncounter
- }
- system: {
- moduleArt: ModuleArt
- remigrate: typeof remigrate
- sluggify: typeof sluggify
- generateItemName: (item: PhysicalItemPF2e) => string
- }
- variantRules: {
- AutomaticBonusProgression: typeof AutomaticBonusProgression
- }
- Check: typeof CheckPF2e
- CheckModifier: typeof CheckModifier
- Coins: typeof CoinsPF2e
- ConditionManager: typeof ConditionManager
- Dice: typeof DicePF2e
- ElementalBlast: typeof ElementalBlast
- Modifier: typeof ModifierPF2e
- ModifierType: {
- [K in Uppercase]: Lowercase
- }
- Predicate: typeof Predicate
- RuleElement: typeof RuleElementPF2e
- RuleElements: typeof RuleElements
- StatisticModifier: typeof StatisticModifier
- StatusEffects: typeof StatusEffects
- TextEditor: typeof TextEditorPF2e
- /** Cached values of frequently-checked settings */
- settings: {
- automation: {
- /** Flanking detection */
- flanking: boolean
- }
- /** Campaign feat slots */
- campaign: {
- feats: {
- enabled: boolean
- sections: FeatGroupOptions[]
- }
- languages: LanguageSettings
- }
- critFumble: {
- buttons: boolean
- cards: boolean
- }
- dragMeasurement: "always" | "encounters" | "never"
- /** Encumbrance automation */
- encumbrance: boolean
- gmVision: boolean
- /** Immunities, weaknesses, and resistances */
- iwr: boolean
- metagame: {
- breakdowns: boolean
- dcs: boolean
- secretChecks: boolean
- partyStats: boolean
- partyVision: boolean
- results: boolean
- }
- /** Rules-based vision */
- rbv: boolean
- tokens: {
- /** Automatic scaling of tokens belong to small actor */
- autoscale: boolean
- /** Token nameplate visibility sets name visibility in encounter tracker */
- nameVisibility: boolean
- /** Nath Mode */
- nathMode: boolean
- }
- /** Theater-of-the-mind toggles */
- totm: boolean
- /** Variant urles */
- variants: {
- /** Automatic Bonus Progression */
- abp: "noABP" | "ABPFundamentalPotency" | "ABPRulesAsWritten"
- /** Free Archetype */
- fa: boolean
- /** Gradual Ability Boosts */
- gab: boolean
- /** Proficiency without Level */
- pwol: {
- enabled: boolean
- /** Modifiers for each proficiency rank */
- modifiers: [number, number, number, number, number]
- }
- /** Stamina */
- stamina: boolean
- }
- }
- }
+ extends Game<
+ ActorPF2e,
+ ActorsPF2e>,
+ ChatMessagePF2e,
+ EncounterPF2e,
+ ItemPF2e,
+ MacroPF2e,
+ ScenePF2e,
+ UserPF2e
+ > {
+ pf2e: {
+ actions: Record & Collection
+ compendiumBrowser: CompendiumBrowser
+ licenseViewer: LicenseViewer
+ worldClock: WorldClock
+ effectPanel: EffectsPanel
+ effectTracker: EffectTracker
+ rollActionMacro: typeof rollActionMacro
+ rollItemMacro: typeof rollItemMacro
+ gm: {
+ calculateXP: typeof calculateXP
+ checkPrompt: typeof checkPrompt
+ editPersistent: typeof editPersistent
+ launchTravelSheet: typeof launchTravelSheet
+ perceptionForSelected: typeof perceptionForSelected
+ stealthForSelected: typeof stealthForSelected
+ xpFromEncounter: typeof xpFromEncounter
+ }
+ system: {
+ moduleArt: ModuleArt
+ remigrate: typeof remigrate
+ sluggify: typeof sluggify
+ generateItemName: (item: PhysicalItemPF2e) => string
+ }
+ variantRules: {
+ AutomaticBonusProgression: typeof AutomaticBonusProgression
+ }
+ Check: typeof CheckPF2e
+ CheckModifier: typeof CheckModifier
+ Coins: typeof CoinsPF2e
+ ConditionManager: typeof ConditionManager
+ Dice: typeof DicePF2e
+ ElementalBlast: typeof ElementalBlast
+ Modifier: typeof ModifierPF2e
+ ModifierType: {
+ [K in Uppercase]: Lowercase
+ }
+ Predicate: typeof Predicate
+ RuleElement: typeof RuleElementPF2e
+ RuleElements: typeof RuleElements
+ StatisticModifier: typeof StatisticModifier
+ StatusEffects: typeof StatusEffects
+ TextEditor: typeof TextEditorPF2e
+ /** Cached values of frequently-checked settings */
+ settings: {
+ automation: {
+ /** Flanking detection */
+ flanking: boolean
+ }
+ /** Campaign feat slots */
+ campaign: {
+ feats: {
+ enabled: boolean
+ sections: FeatGroupOptions[]
+ }
+ languages: LanguageSettings
+ }
+ critFumble: {
+ buttons: boolean
+ cards: boolean
+ }
+ dragMeasurement: "always" | "encounters" | "never"
+ /** Encumbrance automation */
+ encumbrance: boolean
+ gmVision: boolean
+ /** Immunities, weaknesses, and resistances */
+ iwr: boolean
+ metagame: {
+ breakdowns: boolean
+ dcs: boolean
+ secretChecks: boolean
+ partyStats: boolean
+ partyVision: boolean
+ results: boolean
+ }
+ /** Rules-based vision */
+ rbv: boolean
+ tokens: {
+ /** Automatic scaling of tokens belong to small actor */
+ autoscale: boolean
+ /** Token nameplate visibility sets name visibility in encounter tracker */
+ nameVisibility: boolean
+ /** Nath Mode */
+ nathMode: boolean
+ }
+ /** Theater-of-the-mind toggles */
+ totm: boolean
+ /** Variant urles */
+ variants: {
+ /** Automatic Bonus Progression */
+ abp: "noABP" | "ABPFundamentalPotency" | "ABPRulesAsWritten"
+ /** Free Archetype */
+ fa: boolean
+ /** Gradual Ability Boosts */
+ gab: boolean
+ /** Proficiency without Level */
+ pwol: {
+ enabled: boolean
+ /** Modifiers for each proficiency rank */
+ modifiers: [number, number, number, number, number]
+ }
+ /** Stamina */
+ stamina: boolean
+ }
+ }
+ }
}
type ConfiguredConfig = Config<
- AmbientLightDocumentPF2e,
- ActiveEffectPF2e,
- ActorPF2e,
- ActorDeltaPF2e,
- ChatLogPF2e,
- ChatMessagePF2e,
- EncounterPF2e,
- CombatantPF2e,
- EncounterTrackerPF2e,
- CompendiumDirectoryPF2e,
- HotbarPF2e,
- ItemPF2e,
- MacroPF2e,
- MeasuredTemplateDocumentPF2e,
- RegionDocumentPF2e,
- RegionBehaviorPF2e,
- TileDocumentPF2e,
- TokenDocumentPF2e,
- WallDocument,
- ScenePF2e,
- UserPF2e,
- EffectsCanvasGroupPF2e
+ AmbientLightDocumentPF2e,
+ ActiveEffectPF2e,
+ ActorPF2e,
+ ActorDeltaPF2e,
+ ChatLogPF2e,
+ ChatMessagePF2e,
+ EncounterPF2e,
+ CombatantPF2e,
+ EncounterTrackerPF2e,
+ CompendiumDirectoryPF2e,
+ HotbarPF2e,
+ ItemPF2e,
+ MacroPF2e,
+ MeasuredTemplateDocumentPF2e,
+ RegionDocumentPF2e,
+ RegionBehaviorPF2e,
+ TileDocumentPF2e,
+ TokenDocumentPF2e,
+ WallDocument,
+ ScenePF2e,
+ UserPF2e,
+ EffectsCanvasGroupPF2e
>
declare global {
- interface ConfigPF2e extends ConfiguredConfig {
- debug: ConfiguredConfig["debug"] & {
- ruleElement: boolean
- }
- PF2E: typeof PF2ECONFIG
- time: {
- roundTime: number
- }
- }
- const CONFIG: ConfigPF2e
- const canvas: CanvasPF2e
- namespace globalThis {
- var game: GamePF2e
- var fu: typeof foundry.utils
- var ui: FoundryUI<
- ActorDirectoryPF2e,
- ItemDirectory>,
- ChatLogPF2e,
- CompendiumDirectoryPF2e,
- EncounterTrackerPF2e,
- HotbarPF2e
- >
- interface Math {
- eq: (a: number, b: number) => boolean
- gt: (a: number, b: number) => boolean
- gte: (a: number, b: number) => boolean
- lt: (a: number, b: number) => boolean
- lte: (a: number, b: number) => boolean
- ne: (a: number, b: number) => boolean
- ternary: (condition: boolean | number, ifTrue: number, ifFalse: number) => number
- }
- }
- interface Window {
- AutomaticBonusProgression: typeof AutomaticBonusProgression
- }
- interface ClientSettings {
- get(module: "pf2e", setting: "automation.actorsDeadAtZero"): "neither" | "npcsOnly" | "pcsOnly" | "both"
- get(module: "pf2e", setting: "automation.effectExpiration"): boolean
- get(module: "pf2e", setting: "automation.encumbrance"): boolean
- get(module: "pf2e", setting: "automation.flankingDetection"): boolean
- get(module: "pf2e", setting: "automation.iwr"): boolean
- get(module: "pf2e", setting: "automation.lootableNPCs"): boolean
- get(module: "pf2e", setting: "automation.removeExpiredEffects"): boolean
- get(module: "pf2e", setting: "automation.rulesBasedVision"): boolean
- get(module: "pf2e", setting: "gradualBoostsVariant"): boolean
- get(module: "pf2e", setting: "automaticBonusVariant"): "noABP" | "ABPFundamentalPotency" | "ABPRulesAsWritten"
- get(module: "pf2e", setting: "freeArchetypeVariant"): boolean
- get(module: "pf2e", setting: "proficiencyVariant"): boolean
- get(module: "pf2e", setting: "staminaVariant"): boolean
- get(module: "pf2e", setting: "proficiencyUntrainedModifier"): number
- get(module: "pf2e", setting: "proficiencyTrainedModifier"): number
- get(module: "pf2e", setting: "proficiencyExpertModifier"): number
- get(module: "pf2e", setting: "proficiencyMasterModifier"): number
- get(module: "pf2e", setting: "proficiencyLegendaryModifier"): number
- get(module: "pf2e", setting: "metagame_partyVision"): boolean
- get(module: "pf2e", setting: "metagame_secretCondition"): boolean
- get(module: "pf2e", setting: "metagame_secretDamage"): boolean
- get(module: "pf2e", setting: "metagame_showBreakdowns"): boolean
- get(module: "pf2e", setting: "metagame_showDC"): boolean
- get(module: "pf2e", setting: "metagame_showPartyStats"): boolean
- get(module: "pf2e", setting: "metagame_showResults"): boolean
- get(module: "pf2e", setting: "metagame_tokenSetsNameVisibility"): boolean
- get(module: "pf2e", setting: "metagame_secretChecks"): boolean
- get(module: "pf2e", setting: "tokens.autoscale"): boolean
- get(module: "pf2e", setting: "worldClock.dateTheme"): "AR" | "IC" | "AD" | "CE"
- get(module: "pf2e", setting: "worldClock.playersCanView"): boolean
- get(module: "pf2e", setting: "worldClock.showClockButton"): boolean
- get(module: "pf2e", setting: "worldClock.syncDarkness"): boolean
- get(module: "pf2e", setting: "worldClock.timeConvention"): 24 | 12
- get(module: "pf2e", setting: "worldClock.worldCreatedOn"): string
- get(module: "pf2e", setting: "campaignFeats"): boolean
- get(module: "pf2e", setting: "campaignFeatSections"): FeatGroupOptions[]
- get(module: "pf2e", setting: "campaignType"): string
- get(module: "pf2e", setting: "activeParty"): string
- get(module: "pf2e", setting: "activePartyFolderState"): boolean
- get(module: "pf2e", setting: "createdFirstParty"): boolean
- get(module: "pf2e", setting: "homebrew.languages"): HomebrewTag<"languages">[]
- get(module: "pf2e", setting: "homebrew.weaponCategories"): HomebrewTag<"weaponCategories">[]
- get(module: "pf2e", setting: HomebrewTraitSettingsKey): HomebrewTag[]
- get(module: "pf2e", setting: "homebrew.damageTypes"): CustomDamageData[]
- get(module: "pf2e", setting: "homebrew.languageRarities"): LanguageSettings
- get(module: "pf2e", setting: "compendiumBrowserPacks"): CompendiumBrowserSettings
- get(module: "pf2e", setting: "compendiumBrowserSources"): CompendiumBrowserSources
- get(module: "pf2e", setting: "critFumbleButtons"): boolean
- get(module: "pf2e", setting: "critRule"): "doubledamage" | "doubledice"
- get(module: "pf2e", setting: "deathIcon"): ImageFilePath
- get(module: "pf2e", setting: "dragMeasurement"): "always" | "encounters" | "never"
- get(module: "pf2e", setting: "drawCritFumble"): boolean
- get(module: "pf2e", setting: "gmVision"): boolean
- get(module: "pf2e", setting: "identifyMagicNotMatchingTraditionModifier"): 0 | 2 | 5 | 10
- get(module: "pf2e", setting: "minimumRulesUI"): Exclude
- get(module: "pf2e", setting: "nathMode"): boolean
- get(module: "pf2e", setting: "seenRemasterJournalEntry"): boolean
- get(module: "pf2e", setting: "statusEffectType"): StatusEffectIconTheme
- get(module: "pf2e", setting: "totmToggles"): boolean
- get(module: "pf2e", setting: "worldSchemaVersion"): number
- get(module: "pf2e", setting: "worldSystemVersion"): string
- }
- interface ClientSettingsMap {
- get(key: "pf2e.worldClock.worldCreatedOn"): SettingConfig & {
- default: string
- }
- }
- interface RollMathProxy {
- eq: (a: number, b: number) => boolean
- gt: (a: number, b: number) => boolean
- gte: (a: number, b: number) => boolean
- lt: (a: number, b: number) => boolean
- lte: (a: number, b: number) => boolean
- ne: (a: number, b: number) => boolean
- ternary: (condition: boolean | number, ifTrue: number, ifFalse: number) => number
- }
- const BUILD_MODE: "development" | "production"
- const CONDITION_SOURCES: ConditionSource[]
- const EN_JSON: typeof EnJSON
- const ROLL_PARSER: string
- const UUID_REDIRECTS: Record
+ interface ConfigPF2e extends ConfiguredConfig {
+ debug: ConfiguredConfig["debug"] & {
+ ruleElement: boolean
+ }
+ PF2E: typeof PF2ECONFIG
+ time: {
+ roundTime: number
+ }
+ }
+ const CONFIG: ConfigPF2e
+ const canvas: CanvasPF2e
+ namespace globalThis {
+ var game: GamePF2e
+ var fu: typeof foundry.utils
+ var ui: FoundryUI<
+ ActorDirectoryPF2e,
+ ItemDirectory>,
+ ChatLogPF2e,
+ CompendiumDirectoryPF2e,
+ EncounterTrackerPF2e,
+ HotbarPF2e
+ >
+ interface Math {
+ eq: (a: number, b: number) => boolean
+ gt: (a: number, b: number) => boolean
+ gte: (a: number, b: number) => boolean
+ lt: (a: number, b: number) => boolean
+ lte: (a: number, b: number) => boolean
+ ne: (a: number, b: number) => boolean
+ ternary: (condition: boolean | number, ifTrue: number, ifFalse: number) => number
+ }
+ }
+ interface Window {
+ AutomaticBonusProgression: typeof AutomaticBonusProgression
+ }
+ interface ClientSettings {
+ get(
+ module: "pf2e",
+ setting: "automation.actorsDeadAtZero",
+ ): "neither" | "npcsOnly" | "pcsOnly" | "both"
+ get(module: "pf2e", setting: "automation.effectExpiration"): boolean
+ get(module: "pf2e", setting: "automation.encumbrance"): boolean
+ get(module: "pf2e", setting: "automation.flankingDetection"): boolean
+ get(module: "pf2e", setting: "automation.iwr"): boolean
+ get(module: "pf2e", setting: "automation.lootableNPCs"): boolean
+ get(module: "pf2e", setting: "automation.removeExpiredEffects"): boolean
+ get(module: "pf2e", setting: "automation.rulesBasedVision"): boolean
+ get(module: "pf2e", setting: "gradualBoostsVariant"): boolean
+ get(
+ module: "pf2e",
+ setting: "automaticBonusVariant",
+ ): "noABP" | "ABPFundamentalPotency" | "ABPRulesAsWritten"
+ get(module: "pf2e", setting: "freeArchetypeVariant"): boolean
+ get(module: "pf2e", setting: "proficiencyVariant"): boolean
+ get(module: "pf2e", setting: "staminaVariant"): boolean
+ get(module: "pf2e", setting: "proficiencyUntrainedModifier"): number
+ get(module: "pf2e", setting: "proficiencyTrainedModifier"): number
+ get(module: "pf2e", setting: "proficiencyExpertModifier"): number
+ get(module: "pf2e", setting: "proficiencyMasterModifier"): number
+ get(module: "pf2e", setting: "proficiencyLegendaryModifier"): number
+ get(module: "pf2e", setting: "metagame_partyVision"): boolean
+ get(module: "pf2e", setting: "metagame_secretCondition"): boolean
+ get(module: "pf2e", setting: "metagame_secretDamage"): boolean
+ get(module: "pf2e", setting: "metagame_showBreakdowns"): boolean
+ get(module: "pf2e", setting: "metagame_showDC"): boolean
+ get(module: "pf2e", setting: "metagame_showPartyStats"): boolean
+ get(module: "pf2e", setting: "metagame_showResults"): boolean
+ get(module: "pf2e", setting: "metagame_tokenSetsNameVisibility"): boolean
+ get(module: "pf2e", setting: "metagame_secretChecks"): boolean
+ get(module: "pf2e", setting: "tokens.autoscale"): boolean
+ get(module: "pf2e", setting: "worldClock.dateTheme"): "AR" | "IC" | "AD" | "CE"
+ get(module: "pf2e", setting: "worldClock.playersCanView"): boolean
+ get(module: "pf2e", setting: "worldClock.showClockButton"): boolean
+ get(module: "pf2e", setting: "worldClock.syncDarkness"): boolean
+ get(module: "pf2e", setting: "worldClock.timeConvention"): 24 | 12
+ get(module: "pf2e", setting: "worldClock.worldCreatedOn"): string
+ get(module: "pf2e", setting: "campaignFeats"): boolean
+ get(module: "pf2e", setting: "campaignFeatSections"): FeatGroupOptions[]
+ get(module: "pf2e", setting: "campaignType"): string
+ get(module: "pf2e", setting: "activeParty"): string
+ get(module: "pf2e", setting: "activePartyFolderState"): boolean
+ get(module: "pf2e", setting: "createdFirstParty"): boolean
+ get(module: "pf2e", setting: "homebrew.languages"): HomebrewTag<"languages">[]
+ get(module: "pf2e", setting: "homebrew.weaponCategories"): HomebrewTag<"weaponCategories">[]
+ get(module: "pf2e", setting: HomebrewTraitSettingsKey): HomebrewTag[]
+ get(module: "pf2e", setting: "homebrew.damageTypes"): CustomDamageData[]
+ get(module: "pf2e", setting: "homebrew.languageRarities"): LanguageSettings
+ get(module: "pf2e", setting: "compendiumBrowserPacks"): CompendiumBrowserSettings
+ get(module: "pf2e", setting: "compendiumBrowserSources"): CompendiumBrowserSources
+ get(module: "pf2e", setting: "critFumbleButtons"): boolean
+ get(module: "pf2e", setting: "critRule"): "doubledamage" | "doubledice"
+ get(module: "pf2e", setting: "deathIcon"): ImageFilePath
+ get(module: "pf2e", setting: "dragMeasurement"): "always" | "encounters" | "never"
+ get(module: "pf2e", setting: "drawCritFumble"): boolean
+ get(module: "pf2e", setting: "gmVision"): boolean
+ get(module: "pf2e", setting: "identifyMagicNotMatchingTraditionModifier"): 0 | 2 | 5 | 10
+ get(module: "pf2e", setting: "minimumRulesUI"): Exclude
+ get(module: "pf2e", setting: "nathMode"): boolean
+ get(module: "pf2e", setting: "seenRemasterJournalEntry"): boolean
+ get(module: "pf2e", setting: "statusEffectType"): StatusEffectIconTheme
+ get(module: "pf2e", setting: "totmToggles"): boolean
+ get(module: "pf2e", setting: "worldSchemaVersion"): number
+ get(module: "pf2e", setting: "worldSystemVersion"): string
+ }
+ interface ClientSettingsMap {
+ get(key: "pf2e.worldClock.worldCreatedOn"): SettingConfig & {
+ default: string
+ }
+ }
+ interface RollMathProxy {
+ eq: (a: number, b: number) => boolean
+ gt: (a: number, b: number) => boolean
+ gte: (a: number, b: number) => boolean
+ lt: (a: number, b: number) => boolean
+ lte: (a: number, b: number) => boolean
+ ne: (a: number, b: number) => boolean
+ ternary: (condition: boolean | number, ifTrue: number, ifFalse: number) => number
+ }
+ const BUILD_MODE: "development" | "production"
+ const CONDITION_SOURCES: ConditionSource[]
+ const EN_JSON: typeof EnJSON
+ const ROLL_PARSER: string
+ const UUID_REDIRECTS: Record
}
export {}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..8f9f1b9
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,103 @@
+/* eslint-env node */
+import fs from "node:fs"
+import { type Connect, type PluginOption, defineConfig } from "vite"
+import tsconfigPaths from "vite-tsconfig-paths"
+import checker from "vite-plugin-checker"
+import moduleJSON from "./module.json" with { type: "json" }
+
+const packagePath = `modules/${moduleJSON.id}`
+// const { esmodules, styles } = moduleJSON
+
+const skippedFiles = [`${moduleJSON.id}.css`].map((f) => `dist/${f}`).join("|")
+
+export default defineConfig(({ command: _buildOrServe }) => ({
+ root: "src",
+ base: `/${packagePath}/dist`,
+ cacheDir: "../.vite-cache",
+ publicDir: "../assets",
+
+ clearScreen: true,
+
+ esbuild: {
+ target: ["es2022"],
+ },
+
+ resolve: { conditions: ["import", "browser"] },
+
+ server: {
+ open: false,
+ port: 30001,
+ proxy: {
+ // Serves static files from main Foundry server.
+ [`^(/${packagePath}/(assets|lang|packs|${skippedFiles}))`]: "http://localhost:30000",
+
+ // All other paths besides package ID path are served from main Foundry server.
+ [`^(?!/${packagePath}/)`]: "http://localhost:30000",
+
+ // Enable socket.io from main Foundry server.
+ "/socket.io": { target: "ws://localhost:30000", ws: true },
+ },
+ },
+
+ build: {
+ copyPublicDir: false,
+ outDir: "../dist",
+ emptyOutDir: true,
+ sourcemap: true,
+ minify: "terser" as const,
+ terserOptions: {
+ mangle: {
+ toplevel: true,
+ keep_classnames: true,
+ keep_fnames: true,
+ },
+ module: true,
+ },
+ lib: {
+ entry: "vite-index.js",
+ formats: ["es"],
+ fileName: "module",
+ },
+ rollupOptions: {
+ output: {
+ // assetFileNames: assetInfo => (assetInfo.name === 'style.css') ? `${moduleJSON.id}.css` : (assetInfo.name as string),
+ },
+ },
+ },
+
+ optimizeDeps: {
+ esbuildOptions: {
+ target: "es2022",
+ },
+ },
+
+ plugins: [
+ /* process.env.IGNORE_CHECKER
+ ? undefined
+ : checker({
+ typescript: true,
+ }), */
+ tsconfigPaths(),
+ {
+ name: "change-names",
+ configureServer(server) {
+ server.middlewares.use((req: Connect.IncomingMessage & { url?: string }, res, next) => {
+ if (req.originalUrl === `/${packagePath}/dist/module.js`) {
+ req.url = `/${packagePath}/dist/vite-index.js`
+ }
+ next()
+ })
+ },
+ },
+ {
+ name: "create-dist-files",
+ apply: "serve",
+ buildStart() {
+ const files = [...moduleJSON.esmodules, ...moduleJSON.styles]
+ for (const name of files) {
+ fs.writeFileSync(`${name}`, "", { flag: "a" })
+ }
+ },
+ },
+ ],
+}))
diff --git a/yarn.lock b/yarn.lock
index 7e6e8c1..a67ddf3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -307,6 +307,13 @@
dependencies:
"@types/sizzle" "*"
+"@types/node@^22.3.0":
+ version "22.3.0"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-22.3.0.tgz#7f8da0e2b72c27c4f9bd3cb5ef805209d04d4f9e"
+ integrity sha512-nrWpWVaDZuaVc5X84xJ0vNrLvomM205oQyLsRt7OHNZbSHslcWsvgFR7O7hire2ZonjLrWBbedmotmIlJDVd6g==
+ dependencies:
+ undici-types "~6.18.2"
+
"@types/sizzle@*":
version "2.3.8"
resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.8.tgz#518609aefb797da19bf222feb199e8f653ff7627"
@@ -444,6 +451,13 @@ concat-map@0.0.1:
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
+debug@^4.1.1:
+ version "4.3.6"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b"
+ integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==
+ dependencies:
+ ms "2.1.2"
+
esbuild@^0.21.3:
version "0.21.5"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d"
@@ -531,6 +545,11 @@ glob-parent@^5.1.2, glob-parent@~5.1.2:
dependencies:
is-glob "^4.0.1"
+globrex@^0.1.2:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/globrex/-/globrex-0.1.2.tgz#dd5d9ec826232730cd6793a5e33a9302985e6098"
+ integrity sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==
+
graceful-fs@^4.1.6, graceful-fs@^4.2.0:
version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
@@ -604,6 +623,11 @@ minimatch@^3.0.4:
dependencies:
brace-expansion "^1.1.7"
+ms@2.1.2:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
+ integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
+
nanoid@^3.3.7:
version "3.3.7"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8"
@@ -737,6 +761,11 @@ to-regex-range@^5.0.1:
dependencies:
is-number "^7.0.0"
+tsconfck@^3.0.3:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/tsconfck/-/tsconfck-3.1.1.tgz#c7284913262c293b43b905b8b034f524de4a3162"
+ integrity sha512-00eoI6WY57SvZEVjm13stEVE90VkEdJAFGgpFLTsZbJyW/LwFQ7uQxJHWpZ2hzSWgCPKc9AnBnNP+0X7o3hAmQ==
+
type-fest@^0.21.3:
version "0.21.3"
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
@@ -747,6 +776,11 @@ typescript@*:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.8.4.tgz#c464abca159669597be5f96b8943500b238e60e6"
integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==
+undici-types@~6.18.2:
+ version "6.18.2"
+ resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.18.2.tgz#8b678cf939d4fc9ec56be3c68ed69c619dee28b0"
+ integrity sha512-5ruQbENj95yDYJNS3TvcaxPMshV7aizdv/hWYjGIKoANWKjhWNBsr2YEuYZKodQulB1b8l7ILOuDQep3afowQQ==
+
universalify@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"
@@ -772,6 +806,15 @@ vite-plugin-checker@^0.7.2:
vscode-languageserver-textdocument "^1.0.1"
vscode-uri "^3.0.2"
+vite-tsconfig-paths@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/vite-tsconfig-paths/-/vite-tsconfig-paths-5.0.1.tgz#c9387a29c32fd586e4c7f4e2b2da1f0b5c9a7403"
+ integrity sha512-yqwv+LstU7NwPeNqajZzLEBVpUFU6Dugtb2P84FXuvaoYA+/70l9MHE+GYfYAycVyPSDYZ7mjOFuYBRqlEpTig==
+ dependencies:
+ debug "^4.1.1"
+ globrex "^0.1.2"
+ tsconfck "^3.0.3"
+
vite@^5.4.0:
version "5.4.0"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.0.tgz#11dca8a961369ba8b5cae42d068c7ad684d5370f"