diff --git a/package.json b/package.json index b21f0761c..434e92274 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,11 @@ "license": "", "devDependencies": { "@league-of-foundry-developers/foundry-vtt-types": "^0.8.9-2", + "@pyoner/svelte-types": "^3.4.4-2", + "@sveltejs/vite-plugin-svelte": "^1.0.0-next.15", + "@tsconfig/svelte": "^2.0.1", "archiver": "^3.1.1", "chalk": "^3.0.0", - "css-loader": "^5.2.4", "fs-extra": "^9.1.0", "gulp": "^4.0.2", "gulp-git": "^2.10.1", @@ -32,17 +34,22 @@ "prettier": "2.0.5", "rollup-plugin-visualizer": "^5.5.2", "sass": "^1.35.2", + "svelte": "^3.41.0", + "svelte-check": "^2.2.4", + "svelte-language-server": "^0.14.5", + "svelte-preprocess": "^4.7.4", "tslib": "^1.14.1", "typescript": "^4.3.2", "vite": "^2.5.1", - "vite-plugin-checker": "^0.3.4", + "vite-plugin-checker": "https://github.com/SohumB/vite-plugin-checker/releases/download/vite-plugin-checker%400.3.5/vite-plugin-checker-0.3.5.tgz", "yargs": "^15.4.1" }, "dependencies": { - "@mdi/font": "^5.9.55", "@types/marked": "^1.2.1", "aws-amplify": "^4.0.3", "compare-versions": "^3.6.0", + "fp-ts": "^2.10.5", + "io-ts": "^2.2.16", "machine-mind": "0.2.0-beta.123", "marked": "^2.0.3", "tippy.js": "^6.3.1" diff --git a/public/templates/chat/attack-card.hbs b/public/templates/chat/attack-card.hbs index 038d7e379..a5a1fc23a 100644 --- a/public/templates/chat/attack-card.hbs +++ b/public/templates/chat/attack-card.hbs @@ -2,6 +2,9 @@
{{ title }} + + +
{{localize "lancer.chat-card.title.attack"}}
diff --git a/public/templates/chat/tech-attack-card.hbs b/public/templates/chat/tech-attack-card.hbs index 8104aa5d5..7088ca1ab 100644 --- a/public/templates/chat/tech-attack-card.hbs +++ b/public/templates/chat/tech-attack-card.hbs @@ -2,16 +2,19 @@
TECH ATK{{#if title}} :: {{title}}{{/if}} + + +
{{localize "lancer.chat-card.title.attack"}}
{{#each attacks as |attack key|}} -
+
{{ attack.roll.formula }} diff --git a/public/templates/window/acc_diff.hbs b/public/templates/window/acc_diff.hbs deleted file mode 100644 index 9a9fda2a6..000000000 --- a/public/templates/window/acc_diff.hbs +++ /dev/null @@ -1,107 +0,0 @@ -
-
-
-

- - Accuracy -

- - -
-
-

- - Difficulty -

- - - -
-
- -
-
- - - -
-
- - - -
-
- -
-
-
- - 0 - - -
-
-
-
\ No newline at end of file diff --git a/src/global.d.ts b/src/global.d.ts index 4cdde7934..c70e9bd53 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -5,6 +5,12 @@ declare global { game: never; } + interface FlagConfig { + ActiveEffect: { + core?: { statusId?: string }; + }; + } + namespace Game { interface SystemData { id: "lancer"; diff --git a/src/lancer.scss b/src/lancer.scss index 1b51fbf11..3ab6bad26 100644 --- a/src/lancer.scss +++ b/src/lancer.scss @@ -247,6 +247,7 @@ button { .lancer-hit-thumb { width: 50px; + min-width: 50px; margin-right: 4px; border-right: none; border-left: none; @@ -2026,94 +2027,6 @@ a.action { } } - - - -// Acc_Diff -.accdiff-grid { - display: flex; - justify-content: space-between; - - .container { - display: flex; - position: relative; - padding-left: 30px; - margin-top: 12px; - margin-bottom: 4px; - font-size: 0.9em; - user-select: none; - align-items: center; - } - - /* Hide the browser's default checkbox */ - .container input { - position: absolute; - opacity: 0 !important; - height: 0; - width: 0; - } - - .checkmark { - position: absolute; - left: 5px; - height: 20px; - width: 20px; - background-color: #a9a9a9; - cursor: pointer; - } - - input[disabled] ~ .checkmark { - background-color: #313131; - cursor: unset; - } - - .container:hover input:not([disabled]) ~ .checkmark { - background-color: #757575; - } - - .container input:checked:not([disabled]) ~ .checkmark { - background-color: var(--main-theme-color, fuchsia); - } - - .checkmark:after { - content: ""; - position: absolute; - display: none; - } - .container input:checked ~ .checkmark:after { - display: block; - } - - .card.clipped { - display: flex; - flex-direction: row; - align-items: center; - padding: 8px 8px 8px 16px; - background-color: var(--main-theme-color, fuchsia); - color: white; - width: fit-content; - } -} - -.accdiff-other-grid { - width: 100%; - padding-left: 5px; - display: flex; - justify-content: center; -} - -.accdiff-weight { - justify-content: center; - font-weight: bold; -} - -.accdiff-footer { - padding-top: 8px; - padding-bottom: 4px; - margin-top: 12px; - border-top: 1px solid #782e22; -} - // V-IMPORTS .white--text { color: #fff !important; @@ -2198,6 +2111,24 @@ div[data-tab="system"] > .settings-list > div:nth-last-child(-n + 4) { align-self: center; } +// needed to override a builtin that removes all shadows from the sidebar +#sidebar a.chat-button:hover { + text-shadow: 0 0 8px red; +} + +#sidebar a.chat-button { + animation: pulse 1200ms ease 1s 8 alternate; +} + +@keyframes pulse { + 0% { + text-shadow: none; + } + 100% { + text-shadow: 0 0 8px red; + } +} + /* We will add the "dragging-_____" class to the document root whenever we're dragging a type. .For instance, dragging-mech_weapon @@ -2247,3 +2178,14 @@ Here, this means we want to allow for drag stuff etc to show contextually if: text-transform: uppercase; font-family: Roboto,sans-serif !important; } + +/* this forces all its children to share the same space, + which is useful for svelte transitions */ +.grid-enforcement { + display: grid; +} + +.grid-enforcement > * { + grid-column: 1/2; + grid-row: 1/2; +} diff --git a/src/lancer.ts b/src/lancer.ts index a0472f06e..9aecc4158 100644 --- a/src/lancer.ts +++ b/src/lancer.ts @@ -45,6 +45,9 @@ import { compact_tag_list } from "./module/helpers/tags"; import * as migrations from "./module/migration"; import { addLCPManager, updateCore, core_update } from "./module/apps/lcpManager"; +// Import sliding HUD (used for accuracy/difficulty windows) +import * as slidingHUD from "./module/helpers/slidinghud"; + // Import Machine Mind and helpers import * as macros from "./module/macros"; @@ -178,12 +181,15 @@ Hooks.once("init", async function () { prepareItemMacro: macros.prepareItemMacro, prepareStatMacro: macros.prepareStatMacro, prepareTextMacro: macros.prepareTextMacro, + prepareTechMacro: macros.prepareTechMacro, prepareCoreActiveMacro: macros.prepareCoreActiveMacro, prepareCorePassiveMacro: macros.prepareCorePassiveMacro, prepareOverchargeMacro: macros.prepareOverchargeMacro, prepareOverheatMacro: macros.prepareOverheatMacro, prepareStructureMacro: macros.prepareStructureMacro, prepareActivationMacro: macros.prepareActivationMacro, + prepareEncodedAttackMacro: macros.prepareEncodedAttackMacro, + prepareStructureSecondaryRollMacro: macros.prepareStructureSecondaryRollMacro, fullRepairMacro: macros.fullRepairMacro, stabilizeMacro: macros.stabilizeMacro, migrations: migrations, @@ -469,6 +475,17 @@ Hooks.once("init", async function () { // NPC components Handlebars.registerHelper("tier-selector", npc_tier_selector); Handlebars.registerHelper("npc-feat-preview", npc_feature_preview); + + // ------------------------------------------------------------------------ + // Sliding HUD Zone, including accuracy/difficulty window + Hooks.on('renderHeadsUpDisplay', slidingHUD.attach); + Hooks.on('targetToken', (_user: User, _token: Token, isNewTarget: boolean) => { + macros.refreshTargeting(isNewTarget ? "may open new window" : "only refresh open window"); + }); + Hooks.on('createActiveEffect', () => macros.refreshTargeting("only refresh open window")); + Hooks.on('deleteActiveEffect', () => macros.refreshTargeting("only refresh open window")); + // updateToken triggers on things like token movement (spotter) and probably a lot of other things + Hooks.on('updateToken', () => macros.refreshTargeting("only refresh open window")); }); // TODO: either remove when sanity check is no longer needed, or find a better home. @@ -644,10 +661,16 @@ Hooks.on("renderChatMessage", async (cm: ChatMessage, html: any, data: any) => { } html.find(".chat-button").on("click", (ev: MouseEvent) => { - ev.stopPropagation(); - let element = ev.target as HTMLElement; - runEncodedMacro($(element)); - }); + function checkTarget(element: HTMLElement) { + if (element.attributes.getNamedItem('data-macro')) { + ev.stopPropagation(); + runEncodedMacro(element); + return true; + } + return false; + } + checkTarget(ev.target as HTMLElement) || checkTarget(ev.currentTarget as HTMLElement); + }) }); Hooks.on("hotbarDrop", (_bar: any, data: any, slot: number) => { diff --git a/src/module/actor/lancer-actor-sheet.ts b/src/module/actor/lancer-actor-sheet.ts index 0d1c86359..4451c61c6 100644 --- a/src/module/actor/lancer-actor-sheet.ts +++ b/src/module/actor/lancer-actor-sheet.ts @@ -297,7 +297,7 @@ export class LancerActorSheet extends ActorSheet< let encMacros = html.find("a.lancer-macro"); encMacros.on("click", ev => { ev.stopPropagation(); // Avoids triggering parent event handlers - runEncodedMacro($(ev.currentTarget)); + runEncodedMacro(ev.currentTarget); }); /* diff --git a/src/module/actor/lancer-actor.ts b/src/module/actor/lancer-actor.ts index 16fefff33..0929a08c0 100644 --- a/src/module/actor/lancer-actor.ts +++ b/src/module/actor/lancer-actor.ts @@ -203,8 +203,9 @@ export class LancerActor extends Actor { } else { if (result === 1 && remStress === 2) { let macroData = encodeMacroData({ - command: `game.lancer.prepareStatMacro("${ent.RegistryID}","mm.Eng");`, title: "Engineering", + fn: "prepareStatMacro", + args: [ent.RegistryID, "mm.Eng"] }); secondaryRoll = ``; @@ -329,8 +330,9 @@ export class LancerActor extends Actor { } else { if (result === 1 && remStruct === 2) { let macroData = encodeMacroData({ - command: `game.lancer.prepareStatMacro("${ent.RegistryID}","mm.Hull");`, title: "Hull", + fn: "prepareStatMacro", + args: [ent.RegistryID, "mm.Hull"] }); secondaryRoll = ``; @@ -338,33 +340,9 @@ export class LancerActor extends Actor { let macroData = encodeMacroData({ // TODO: Should create a "prepareRollMacro" or something to handle generic roll-based macros // Since we can't change prepareTextMacro too much or break everyone's macros - command: ` - let roll = new Roll('1d6').evaluate({async: false}); - let result = roll.total; - if(result<=3) { - game.lancer.prepareTextMacro("${ent.RegistryID}","Destroy Weapons",\` -
-
-
- \${ roll.formula } - \${ result } -
-
-
- On a 1–3, all weapons on one mount of your choice are destroyed\`); - } else { - game.lancer.prepareTextMacro("${ent.RegistryID}","Destroy Systems",\` -
-
-
- \${ roll.formula } - \${ result } -
-
-
- On a 4–6, a system of your choice is destroyed\`); - }`, title: "Roll for Destruction", + fn: "prepareStructureSecondaryRollMacro", + args: [ent.RegistryID] }); secondaryRoll = ``; diff --git a/src/module/helpers/acc_diff.ts b/src/module/helpers/acc_diff.ts deleted file mode 100644 index 8ac020029..000000000 --- a/src/module/helpers/acc_diff.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { TagInstance } from "machine-mind"; - -export type AccDiffFlag = "ACCURATE" | "INACCURATE" | "SOFT_COVER" | "HARD_COVER" | "SEEKING"; -export const AccDiffRegistry: Record = { - ACCURATE: 1, - INACCURATE: -1, - SOFT_COVER: -1, - HARD_COVER: -2, - SEEKING: 0, -}; - -export function calcAccDiff() { - let acc = 0; - let diff = 0; - let flags: AccDiffFlag[] = []; - document - .querySelectorAll("[data-acc]:checked:not([disabled])") - .forEach(ele => flags.push(ele.getAttribute("data-acc") as AccDiffFlag)); - document - .querySelectorAll("[data-diff]:checked:not([disabled])") - .forEach(ele => flags.push(ele.getAttribute("data-diff") as AccDiffFlag)); - - const isSeeking = flags.includes("SEEKING"); - for (let flag of flags) { - switch (flag) { - case "SOFT_COVER": - case "HARD_COVER": - diff += isSeeking ? 0 : AccDiffRegistry[flag]; - break; - case "ACCURATE": - acc += 1; - break; - case "INACCURATE": - diff -= 1; - break; - case "SEEKING": - break; - } - } - return [acc, -diff]; -} - -function calcManualAccDiff() { - const acc = parseInt((document.querySelector(`#accdiff-other-acc`) as HTMLInputElement)?.value); - const diff = parseInt((document.querySelector(`#accdiff-other-diff`) as HTMLInputElement)?.value); - return [acc, diff]; -} - -export function tagsToFlags(tags: TagInstance[]): AccDiffFlag[] { - const ret: AccDiffFlag[] = []; - tags.forEach(tag => { - switch (tag.Tag.LID) { - case "tg_accurate": - ret.push("ACCURATE"); - break; - case "tg_inaccurate": - ret.push("INACCURATE"); - break; - case "tg_seeking": - ret.push("SEEKING"); - break; - } - }); - return ret; -} - -// DOM Manipulation -export function toggleCover(toggle: boolean) { - const ret = document.querySelectorAll('[data-accdiff="SOFT_COVER"],[data-accdiff="HARD_COVER"]'); - ret.forEach(ele => (toggle ? ele.removeAttribute("disabled") : ele.setAttribute("disabled", "true"))); -} - -export function updateTotals() { - const flags = calcAccDiff(); - const other = calcManualAccDiff(); - const totalAcc = flags[0] + other[0]; - const totalDiff = flags[1] + other[1]; - const fullTotal = totalAcc - totalDiff; - - // SEPARATE SUBTOTALS - // const accEle = document.querySelector("#accdiff-total-acc"); - // const diffEle = document.querySelector("#accdiff-total-diff"); - // accEle && (accEle.innerHTML = String(totalAcc)); - // diffEle && (diffEle.innerHTML = String(totalDiff)); - - // SINGLE TOTAL - const accEle = document.querySelector("#accdiff-total"); - if (accEle) { - accEle.innerHTML = String(Math.abs(fullTotal)); - - // Change color based on result. - const color = fullTotal > 0 ? "#017934" : fullTotal < 0 ? "#9c0d0d" : "#443c3c"; - accEle.parentElement!.style.backgroundColor = color; - - // Change icon based on result. - fullTotal > 0 && accEle.nextElementSibling?.classList.replace("cci-difficulty", "cci-accuracy"); - fullTotal < 0 && accEle.nextElementSibling?.classList.replace("cci-accuracy", "cci-difficulty"); - } - - return fullTotal; -} diff --git a/src/module/helpers/acc_diff/ConsumeLockOn.svelte b/src/module/helpers/acc_diff/ConsumeLockOn.svelte new file mode 100644 index 000000000..da76dc3f5 --- /dev/null +++ b/src/module/helpers/acc_diff/ConsumeLockOn.svelte @@ -0,0 +1,25 @@ + + + + + diff --git a/src/module/helpers/acc_diff/Cover.svelte b/src/module/helpers/acc_diff/Cover.svelte new file mode 100644 index 000000000..4f68e658d --- /dev/null +++ b/src/module/helpers/acc_diff/Cover.svelte @@ -0,0 +1,81 @@ + + + + +
+ {#each inputs as input} + + + {/each} +
+ + diff --git a/src/module/helpers/acc_diff/Form.svelte b/src/module/helpers/acc_diff/Form.svelte new file mode 100644 index 000000000..ff1643f78 --- /dev/null +++ b/src/module/helpers/acc_diff/Form.svelte @@ -0,0 +1,389 @@ + + + + +
dispatch('submit')}> + {#if title != ''} +
+ {#if kind == "attack"} + + {:else if kind == "hase"} + + {/if} + {title} +
+ {/if} +
+
+
+

+ + Accuracy +

+ + {#if kind == "attack"} + + {/if} + {#if kind == "attack" && (Object.values(weapon.plugins).length > 0 || targets.length == 1)} +
+

+ +  Misc +

+ {#each Object.keys(weapon.plugins) as key} + + {/each} + {#if targets.length == 1} + + {#each Object.keys(targets[0].plugins) as key} + + {/each} + {/if} +
+ {/if} +
+
+

+ + Difficulty +

+ + + {#if kind == "attack"} +
+ {#if targets.length == 0} +
+ +
+ {:else if targets.length == 1} +
+ +
+ {/if} +
+ {/if} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ + diff --git a/src/module/helpers/acc_diff/Plugin.svelte b/src/module/helpers/acc_diff/Plugin.svelte new file mode 100644 index 000000000..d8935c4b9 --- /dev/null +++ b/src/module/helpers/acc_diff/Plugin.svelte @@ -0,0 +1,13 @@ + + +{#if data.uiElement == "checkbox" && data.visible} + +{/if} diff --git a/src/module/helpers/acc_diff/PlusMinusInput.svelte b/src/module/helpers/acc_diff/PlusMinusInput.svelte new file mode 100644 index 000000000..e1296302a --- /dev/null +++ b/src/module/helpers/acc_diff/PlusMinusInput.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/src/module/helpers/acc_diff/Total.svelte b/src/module/helpers/acc_diff/Total.svelte new file mode 100644 index 000000000..861180401 --- /dev/null +++ b/src/module/helpers/acc_diff/Total.svelte @@ -0,0 +1,197 @@ + + + + +{#if isTarget(target)} +
+ {target.target.data.name + +
+ {#if !onlyTarget} +
+ {#each Object.keys(target.plugins) as key} + + {/each} +
+ {/if} +{/if} +
+
0} class:inaccurate={target.total < 0}> + + {#each [target.total] as total (target.total)} +
+ + {Math.abs(total)} + + = 0} + class:cci-difficulty={total < 0} > +
+ {/each} +
+
+ + diff --git a/src/module/helpers/acc_diff/data.ts b/src/module/helpers/acc_diff/data.ts new file mode 100644 index 000000000..39ca347f1 --- /dev/null +++ b/src/module/helpers/acc_diff/data.ts @@ -0,0 +1,390 @@ +import type { TagInstance } from "machine-mind"; +import * as t from 'io-ts'; + +import type { LancerActor } from "../../actor/lancer-actor"; +import type { AccDiffPlugin, AccDiffPluginData, AccDiffPluginCodec } from './plugin'; +import { enclass, encode, decode } from './serde'; +import { LancerItem } from "../../item/lancer-item"; + +import Invisibility from "./invisibility"; +import Spotter from "./spotter"; +import { LancerToken } from "../../token"; + +export function findEffect(actor: LancerActor, effect: string): ActiveEffect | null { + return actor.data.effects.find(eff => eff.data.flags.core?.statusId?.endsWith(effect) ?? false) ?? null; +} + +export enum Cover { + None = 0, + Soft = 1, + Hard = 2 +} +let coverSchema = t.union([t.literal(0), t.literal(1), t.literal(2)]); + +// so normally you wouldn't keep the codecs with the classes like this +// the entire point of io-ts is that the co/dec logic is separable +// but here we want plugins to actually modify the codecs, so, sigh +export class AccDiffWeapon { + accurate: boolean; + inaccurate: boolean; + seeking: boolean; + #data!: AccDiffData; // never use this class before calling hydrate + plugins: { [k: string]: AccDiffPluginData }; + + static pluginSchema: { [k: string]: AccDiffPluginCodec } = { + + }; + + static get schema() { + return { + accurate: t.boolean, + inaccurate: t.boolean, + seeking: t.boolean, + plugins: t.type(this.pluginSchema), + } + } + + static get schemaCodec() { + return t.type(this.schema); + } + static get codec() { return enclass(this.schemaCodec, AccDiffWeapon) } + + constructor(obj: t.TypeOf) { + this.accurate = obj.accurate; + this.inaccurate = obj.inaccurate; + this.seeking = obj.seeking; + this.plugins = obj.plugins; + } + + get raw() { + return { + accurate: this.accurate, + inaccurate: this.inaccurate, + seeking: this.seeking, + plugins: this.plugins + } + } + + get impaired(): ActiveEffect | null { + return (this.#data?.lancerActor && findEffect(this.#data.lancerActor, "impaired")) ?? null; + } + + total(cover: number) { + return (this.accurate ? 1 : 0) - (this.inaccurate ? 1 : 0) + - (this.seeking ? 0 : cover) - (this.impaired ? 1 : 0); + } + + hydrate(d: AccDiffData) { + for (let key of Object.keys(this.plugins)) { + this.plugins[key].hydrate(d); + } + this.#data = d; + } +} + +export class AccDiffBase { + accuracy: number; + difficulty: number; + cover: Cover; + plugins: { [k: string]: AccDiffPluginData }; + #weapon!: AccDiffWeapon; // never use this class before calling hydrate + + static pluginSchema: { [k: string]: AccDiffPluginCodec } = { + + }; + + static get schema() { + return { + accuracy: t.number, + difficulty: t.number, + cover: coverSchema, + plugins: t.type(this.pluginSchema) + } + } + static get schemaCodec() { return t.type(this.schema); } + static get codec() { return enclass(this.schemaCodec, AccDiffBase) } + + constructor(obj: t.TypeOf) { + this.accuracy = obj.accuracy; + this.difficulty = obj.difficulty; + this.cover = obj.cover; + this.plugins = obj.plugins; + // this.#weapon = weapon; + } + + get raw() { + return { accuracy: this.accuracy, difficulty: this.difficulty, cover: this.cover, plugins: this.plugins } + } + + hydrate(d: AccDiffData) { + this.#weapon = d.weapon; + for (let key of Object.keys(this.plugins)) { + this.plugins[key].hydrate(d, this); + } + } + + get total() { + return this.accuracy - this.difficulty + this.#weapon.total(this.cover); + } +} + +// we _want_ to extend AccDiffBase +// but ... typescript checks type compatibility between _static_ methods +// and that + io-ts I think has the variance wrong +// so if you extend AccDiffBase it's trying to assign AccDiffBase to AccDiffTarget +export class AccDiffTarget { + target: LancerToken; + accuracy: number; + difficulty: number; + cover: Cover; + consumeLockOn: boolean; + plugins: { [k: string]: any }; + #weapon!: AccDiffWeapon; // never use this class before calling hydrate + #base!: AccDiffBase; // never use this class before calling hydrate + + static pluginSchema: { [k: string]: AccDiffPluginCodec } = { + + }; + + static get schema() { + return { + target_id: t.string, + accuracy: t.number, + difficulty: t.number, + cover: coverSchema, + consumeLockOn: t.boolean, + plugins: t.type(this.pluginSchema), + } + } + + static get schemaCodec() { return t.type(this.schema); } + static get codec() { return enclass(this.schemaCodec, AccDiffTarget) } + + constructor(obj: t.TypeOf) { + let target = canvas!.scene!.tokens.get(obj.target_id); + if (!target) { + ui.notifications!.error("Trying to access tokens from a different scene!"); + throw new Error("Token not found"); + } + + this.target = target.object! as LancerToken; + this.accuracy = obj.accuracy; + this.difficulty = obj.difficulty; + this.cover = obj.cover; + this.consumeLockOn = obj.consumeLockOn; + this.plugins = obj.plugins; + // this.#weapon = weapon; + // this.#base = base; + } + + get raw() { + return { + target_id: this.target.id, + accuracy: this.accuracy, + difficulty: this.difficulty, + cover: this.cover, + consumeLockOn: this.consumeLockOn, + plugins: this.plugins, + } + } + + static fromParams(t: Token): AccDiffTarget { + let ret = { + target_id: t.id, + accuracy: 0, + difficulty: 0, + cover: Cover.None, + consumeLockOn: true, + plugins: {} as { [k: string]: any }, + }; + for (let plugin of AccDiffData.targetedPlugins) { + ret.plugins[plugin.slug] = encode(plugin.perTarget!(t), plugin.codec); + } + return decode(ret, AccDiffTarget.codec); + } + + hydrate(d: AccDiffData) { + this.#weapon = d.weapon; + this.#base = d.base; + for (let key of Object.keys(this.plugins)) { + this.plugins[key].hydrate(d, this); + } + } + + get usingLockOn(): null | ActiveEffect { + return (this.consumeLockOn && this.lockOnAvailable) || null; + } + + get lockOnAvailable(): null | ActiveEffect { + return findEffect(this.target.actor as LancerActor, "lockon"); + } + + get total() { + let base = this.accuracy - this.difficulty + this.#weapon.total(this.cover); + // the only thing we actually use base for is the untyped bonuses + let raw = base + this.#base.accuracy - this.#base.difficulty; + let lockon = this.usingLockOn ? 1 : 0; + + return raw + lockon; + } +} + +export type AccDiffDataSerialized = t.OutputOf; +export class AccDiffData { + title: string; + weapon: AccDiffWeapon; + base: AccDiffBase; + targets: AccDiffTarget[]; + lancerItem?: LancerItem; // not persisted, needs to be hydrated + lancerActor?: LancerActor; // not persisted, needs to be hydrated + + static get schema() { + return { + title: t.string, + weapon: AccDiffWeapon.codec, + base: AccDiffBase.codec, + targets: t.array(AccDiffTarget.codec) + } + } + + static get schemaCodec() { return t.type(this.schema); } + static get codec() { return enclass(this.schemaCodec, AccDiffData) } + + constructor(obj: t.TypeOf) { + this.title = obj.title; + this.weapon = obj.weapon; + this.base = obj.base; + this.targets = obj.targets; + this.hydrate(); + } + + hydrate(runtimeData?: LancerItem | LancerActor) { + if (runtimeData instanceof LancerItem) { + this.lancerItem = runtimeData; + this.lancerActor = runtimeData.actor ?? undefined; + } else { + this.lancerActor = runtimeData ?? undefined; + } + + this.weapon.hydrate(this); + this.base.hydrate(this); + for (let target of this.targets) { target.hydrate(this); } + } + + replaceTargets(ts: Token[]): AccDiffData { + let oldTargets: { [key: string]: AccDiffTarget } = {}; + for (let data of this.targets) { oldTargets[data.target.id] = data; } + + this.targets = ts.map(t => oldTargets[t.id] ?? AccDiffTarget.fromParams(t)); + + for (let target of this.targets) { target.hydrate(this); } + return this; + } + + get raw() { + return { + title: this.title, + weapon: this.weapon, + base: this.base, + targets: this.targets, + } + } + + static fromObject(obj: AccDiffDataSerialized, runtimeData?: LancerItem | LancerActor): AccDiffData { + let ret = decode(obj, AccDiffData.codec); + ret.hydrate(runtimeData); + return ret; + } + + toObject(): t.OutputOf { + return encode(this, AccDiffData.codec); + } + + static plugins: AccDiffPlugin[] = []; + static targetedPlugins: AccDiffPlugin[] = []; + static registerPlugin>(plugin: P) { + if (plugin.perRoll) { + AccDiffWeapon.pluginSchema[plugin.slug] = plugin.codec; + } + if (plugin.perUnknownTarget) { + AccDiffBase.pluginSchema[plugin.slug] = plugin.codec; + } + if (plugin.perTarget) { + AccDiffTarget.pluginSchema[plugin.slug] = plugin.codec; + this.targetedPlugins.push(plugin); + } + this.plugins.push(plugin); + } + + static fromParams( + runtimeData?: LancerItem | LancerActor, + tags?: TagInstance[], + title?: string, + targets?: Token[], + starting?: [number, number] + ): AccDiffData { + let weapon = { + accurate: false, + inaccurate: false, + seeking: false, + plugins: {} as { [k: string]: any }, + }; + + for (let tag of (tags || [])) { + switch (tag.Tag.LID) { + case "tg_accurate": + weapon.accurate = true; + break; + case "tg_inaccurate": + weapon.inaccurate = true; + break; + case "tg_seeking": + weapon.seeking = true; + break; + } + } + + let base = { + cover: Cover.None, + accuracy: starting ? starting[0] : 0, + difficulty: starting ? starting[1] : 0, + plugins: {} as { [k: string]: any }, + }; + + let obj: AccDiffDataSerialized = { + title: title ? title : 'Accuracy and Difficulty', + weapon, base, + targets: (targets || []).map(t => { + let ret = { + target_id: t.id, + accuracy: 0, + difficulty: 0, + cover: Cover.None, + consumeLockOn: true, + plugins: {} as { [k: string]: any }, + }; + for (let plugin of this.targetedPlugins) { + ret.plugins[plugin.slug] = encode(plugin.perTarget!(t), plugin.codec); + } + return ret; + }) + }; + + for (let plugin of this.plugins) { + if (plugin.perRoll) { + obj.weapon.plugins[plugin.slug] = encode(plugin.perRoll(runtimeData), plugin.codec); + } + if (plugin.perUnknownTarget) { + obj.base.plugins[plugin.slug] = encode(plugin.perUnknownTarget(), plugin.codec); + } + } + + // for now this isn't using AccDiffTarget.fromParams, which means the code is duplicated + // that's a relatively contained bit of tech debt, but let's handle it next time this is touched + return AccDiffData.fromObject(obj, runtimeData); + } +} + +// side effects for importing, yes, yes, I know +AccDiffData.registerPlugin(Invisibility); +AccDiffData.registerPlugin(Spotter); diff --git a/src/module/helpers/acc_diff/index.ts b/src/module/helpers/acc_diff/index.ts new file mode 100644 index 000000000..a719e3eda --- /dev/null +++ b/src/module/helpers/acc_diff/index.ts @@ -0,0 +1,23 @@ +export type { + AccDiffDataSerialized +} from './data'; +export { + findEffect, + Cover, + AccDiffWeapon, + AccDiffBase, + AccDiffTarget, + AccDiffData, +} from './data'; +export type { + CheckboxUI, + NoUI, + RollModifier, + Dehydrated, + AccDiffPluginData, + AccDiffCheckboxPluginData, + AccDiffNoUIPluginData, + AccDiffPluginCodec, + AccDiffPlugin, + Data as AccDiffPluginDataOf +} from './plugin' diff --git a/src/module/helpers/acc_diff/invisibility.ts b/src/module/helpers/acc_diff/invisibility.ts new file mode 100644 index 000000000..4bf1afb25 --- /dev/null +++ b/src/module/helpers/acc_diff/invisibility.ts @@ -0,0 +1,101 @@ +import * as t from 'io-ts'; +import { LancerActor } from "../../actor/lancer-actor"; +import { AccDiffPlugin, AccDiffCheckboxPluginData, AccDiffPluginCodec } from './plugin'; +import { AccDiffData, AccDiffTarget, findEffect } from './index'; +import { enclass } from './serde'; + +// you don't need to explicitly type the serialized data, +// but if you do then io-ts codecs can do strong checks at runtime + +// so for invisibility: why not persist just a boolean? +// we want to try and capture the intent for the reroll, not just the state +// if the user clicks to force invisible, then clicks it again +// we want to interpret that as changing their mind, instead of forcing _visibility_ +export enum InvisibilityEnum { + ForceVisibility = -1, + NoForce = 0, + ForceInvisibility = 1, +} + +export default class Invisibility implements AccDiffCheckboxPluginData { + data: InvisibilityEnum; + token?: Token; + + // these methods are for easy class codecs via `enclass` + constructor(ser: InvisibilityEnum) { this.data = ser; } + get raw(): InvisibilityEnum { return this.data; } + + // as you may have guessed, the codec just stores the enum + static get codec(): AccDiffPluginCodec { + return enclass( + t.union([t.literal(-1), t.literal(0), t.literal(1)]), + Invisibility + ) + } + + // store a reference to the current token when rehydrated + hydrate(_d: AccDiffData, t?: AccDiffTarget) { + if (t) { this.token = t.target; } + } + + // invisibility operates on the target level, whether we know the target or not + static perUnknownTarget(): Invisibility { + return new Invisibility(InvisibilityEnum.NoForce); + } + static perTarget(item: Token): Invisibility { + let ret = Invisibility.perUnknownTarget(); + ret.token = item; + return ret; + } + + // assume targets aren't invisible if we don't know about them + // otherwise, go get the status effects and check them + private get tokenInvisible(): boolean { + if (!this.token) { return false; } + return !!findEffect(this.token.actor as LancerActor, "invisible"); + } + + // UI behaviour + uiElement: "checkbox" = "checkbox"; + slug: string = "invisibility"; + static slug: string = "invisibility"; + humanLabel: string = "Invisible (*)"; + + // our uiState is whether we're treating the current target as invisible + get uiState() { + if (this.data == InvisibilityEnum.NoForce) { + return this.tokenInvisible; + } else { + return !!(this.data + 1); + } + } + // toggling invisibility to what the token isn't is interpreted as a force + // toggling invisibility to whatever the token is is interpreted as not wanting to force + set uiState(newState: boolean) { + let tki = this.tokenInvisible; + if (tki == newState) { + this.data = InvisibilityEnum.NoForce; + } else if (newState) { + this.data = InvisibilityEnum.ForceInvisibility; + } else { + this.data = InvisibilityEnum.ForceVisibility; + } + } + + // no behaviour here — invisibility can always be seen and toggled + readonly visible = true; + readonly disabled = false; + + // the effect to have on the roll + // 1d2even resolves to either 0 or 1 successes, so multiplying works great + modifyRoll(roll: string): string { + if (this.uiState) { + return `{${roll}} * (1dc[👻 invisibility])`; + } else { + return roll; + } + } +} + +// to check whether the static methods match the interface +const _klass: AccDiffPlugin = Invisibility; diff --git a/src/module/helpers/acc_diff/plugin.d.ts b/src/module/helpers/acc_diff/plugin.d.ts new file mode 100644 index 000000000..5b604605c --- /dev/null +++ b/src/module/helpers/acc_diff/plugin.d.ts @@ -0,0 +1,65 @@ +import type * as t from 'io-ts'; +import { LancerItem } from "../../item/lancer-item"; + +import type { AccDiffData } from './index'; + +// Implementing a plugin means implementing +// * a data object that can compute its view behaviour, +// * a codec to serialize it, +// * and a bunch of freestanding constructors + +// You don't _have_ to make the data object a class with static methods for the constructors +// but it's convenient + +declare interface CheckboxUI { + uiElement: "checkbox" = "checkbox", + slug: string, + humanLabel: string, + get uiState(): boolean; + set uiState(data: boolean): this; + get disabled(): boolean; + get visible(): boolean; +} + +declare interface NoUI { + uiElement: "none" = "none", +} + +type UIBehaviour = CheckboxUI | NoUI; + +declare interface RollModifier { + modifyRoll(roll: string): string +} + +declare interface Dehydrated { + // the codec handles all serializable data, + // but we might want to pick up data from the environment too + // all perTarget codecs get the target as well + hydrate(data: AccDiffData, target?: AccDiffTarget); +} + +export type AccDiffPluginData = UIBehaviour & RollModifier & Dehydrated; +export type AccDiffCheckboxPluginData = CheckboxUI & RollModifier & Dehydrated; +export type AccDiffNoUIPluginData = NoUI & RollModifier & Dehydrated; + +export type AccDiffPluginCodec = Codec; + +declare interface AccDiffPlugin { + slug: string, + // the codec lets us know how to persist whatever data you need for rerolls + codec: AccDiffPluginCodec, + // these constructors handle creating the initial data for a plugin + // the presence of these three constructors also indicates what scopes the plugin lives in + // a "perRoll" plugin applies to all rolls, like weapon seeking + // a "perTarget" plugin applies individually to every single target + // a "perUnknownTarget" applies whenever the user opens the roll dialog without a target + // so every roll has perRoll + exactly one of perTarget and perUnknownTarget + perRoll?(item?: LancerItem | LancerActor): Data, + perUnknownTarget?(): Data, + perTarget?(item: Token): Data, + // usually you want to implement either perRoll OR both of the other two + // if you implement perRoll AND either or both of the other two, `rollModifier` + // will be called twice on the same roll, so watch out for that +} + +export type Data = T extends AccDiffPlugin ? D : never; diff --git a/src/module/helpers/acc_diff/serde.ts b/src/module/helpers/acc_diff/serde.ts new file mode 100644 index 000000000..02899c47a --- /dev/null +++ b/src/module/helpers/acc_diff/serde.ts @@ -0,0 +1,35 @@ +import * as t from 'io-ts'; +import { isLeft, right, Either, map } from 'fp-ts/Either'; + +function unwrap(e: Either): A { + if (isLeft(e)) { + throw e.left; + } else { + return e.right; + } +} + +export function enclass( + codec: t.Type, + Constructor: new (bag: Raw) => Klass) { + return new t.Type( + Constructor.name, + (v: unknown): v is Klass => v instanceof Constructor, + data => map((d: Raw) => new Constructor(d))(codec.decode(data)), + inst => codec.encode(inst.raw) + ); +} + +export function stateless(name: string, predicate: t.Is, synthesise: () => T) { + return new t.Type( + name, predicate, + _ => right(synthesise()), _ => null + ); +} + +export function encode(t: T, codec: t.Type): Raw { + return codec.encode(t); +} +export function decode(r: Raw, codec: t.Type): T { + return unwrap(codec.decode(r)); +} diff --git a/src/module/helpers/acc_diff/spotter.ts b/src/module/helpers/acc_diff/spotter.ts new file mode 100644 index 000000000..98fbac351 --- /dev/null +++ b/src/module/helpers/acc_diff/spotter.ts @@ -0,0 +1,76 @@ +import { stateless } from './serde'; +import type { AccDiffPlugin, AccDiffPluginData } from './plugin'; +import type { AccDiffData, AccDiffTarget } from './index'; +import type { LancerActor } from '../../actor/lancer-actor'; +import type { Mech, Pilot } from 'machine-mind'; + +// this is an example of a case implemented without defining a full class +function adjacentSpotter(actor: LancerActor): boolean { + // only players can benefit from spotter + if (!actor.is_mech()) { return false; } + + // this isn't adjacency, it's "is within range 1 LOS with a hack for larger mechs", but it's good enough + // computation taken from sensor-sight + let radius = actor.data.data.derived.mm!.Size; + let token = actor.getActiveTokens()[0]; + // TODO: TYPECHECK: center does always seem to exist on this thing ts thinks is a LancerTokenDocument + let point = (token as any).center; + + function inRange(token: { x: number, y: number }) { + const range = Math.sqrt((token.x - point.x) * (token.x - point.x) + (token.y - point.y) * (token.y - point.y)); + const scale = canvas!.scene!.data.gridType > 1 ? Math.sqrt(3) / 2 : 1; // for hexes + const grid = canvas!.scene!.data.grid; + return (radius + .01) * grid * scale > range; + } + + // TODO: TYPECHECK: all of this seems to work + let adjacentPilots = (canvas!.tokens!.objects!.children as Token[]) + .filter((t: Token) => t.actor?.is_mech() && inRange((t as any).center) && t.id != token.id) + .map((t: Token) => (t.actor!.data.data.derived.mm! as Mech).Pilot); + + return !!(adjacentPilots.find((p: Pilot | null) => p?.Talents.find(t => t.LID == "t_spotter"))); +} + +function spotter(): AccDiffPluginData { + let sp = { + actor: null as LancerActor | null, + target: null as AccDiffTarget | null, + uiElement: "checkbox" as "checkbox", + slug: "spotter", + humanLabel: "Spotter (*)", + get uiState() { + return !!(this.actor && this.target?.usingLockOn && adjacentSpotter(this.actor)) + }, + set uiState(_v: boolean) { + // noop + }, + disabled: true, + get visible() { + return !!(this.target?.usingLockOn); + }, + modifyRoll(roll: string) { + if (this.uiState) { + return roll.replace('1d20', '2d20kh1[spotter]'); + } else { + return roll; + } + }, + hydrate(data: AccDiffData, target?: AccDiffTarget) { + this.actor = data.lancerActor || null; + this.target = target || null; + } + }; + + return sp; +} + +const Spotter: AccDiffPlugin = { + slug: "spotter", + codec: stateless("Spotter", + (t: unknown): t is AccDiffPluginData => typeof t == 'object' && (t as any)?.slug == "spotter", + spotter + ), + perTarget(_t: Token) { return spotter() } +} + +export default Spotter; diff --git a/src/module/helpers/actor.ts b/src/module/helpers/actor.ts index d22a35a4a..5a8a308af 100644 --- a/src/module/helpers/actor.ts +++ b/src/module/helpers/actor.ts @@ -52,8 +52,12 @@ export function stat_view_card( let data_val = resolve_helper_dotpath(options, data_path); let macro_button: string | undefined; let macroData = encodeMacroData({ - command: `game.lancer.prepareStatMacro("${options.data.root.data._id}","${data_path}");`, title: title, + fn: "prepareStatMacro", + args: [ + options.data.root.data._id, + data_path + ] }); if (options.rollable) macro_button = ``; @@ -125,8 +129,12 @@ export function clicker_stat_card( ): string { let button = ""; let macroData = encodeMacroData({ - command: `game.lancer.prepareStatMacro("${options.data.root.data._id}","${data_path}");`, title: title, + fn: "prepareStatMacro", + args: [ + options.data.root.data._id, + data_path + ] }); if (roller) button = ``; diff --git a/src/module/helpers/dragdrop.ts b/src/module/helpers/dragdrop.ts index de84aa79d..3c42cae6e 100644 --- a/src/module/helpers/dragdrop.ts +++ b/src/module/helpers/dragdrop.ts @@ -59,6 +59,8 @@ export function HANDLER_enable_dropping( let data = event.originalEvent?.dataTransfer?.getData("text/plain"); if (!data) return; + import("./slidinghud").then(mod => mod.fade("out")); + // Check if we can drop let drop_permitted = !allow_drop || allow_drop(data, item, event); @@ -124,6 +126,8 @@ export function HANDLER_enable_dropping( event.preventDefault(); event.stopPropagation(); + + import("./slidinghud").then(mod => mod.fade("in")); }); }); } @@ -596,6 +600,7 @@ export function applyGlobalDragListeners() { body.addEventListener( "dragstart", e => { + import("./slidinghud").then(mod => mod.fade("out")); // Even though we are capturing, we need to wait a moment so the event data transfer can occur setTimeout(async () => { // Ok. Try to resolve @@ -633,6 +638,7 @@ export function applyGlobalDragListeners() { body.addEventListener( "dragend", e => { + import("./slidinghud").then(mod => mod.fade("in")); clear_global_drag(); }, { diff --git a/src/module/helpers/loadout.ts b/src/module/helpers/loadout.ts index 453aa2837..fb9ad5339 100644 --- a/src/module/helpers/loadout.ts +++ b/src/module/helpers/loadout.ts @@ -265,11 +265,11 @@ function frame_active(actor: LancerActor, core: CoreSystem): string { }).join(""); // Should find a better way to do this... - let coreMacroData: LancerMacroData = { - command: `game.lancer.prepareCoreActiveMacro("${actor.id}")`, title: `${actor.name} | CORE POWER`, iconPath: `systems/${game.system.id}/assets/icons/macro-icons/corebonus.svg`, + fn: "prepareCoreActiveMacro", + args: [actor.id] }; return ` diff --git a/src/module/helpers/reactive-form.ts b/src/module/helpers/reactive-form.ts new file mode 100644 index 000000000..c10ca9ae1 --- /dev/null +++ b/src/module/helpers/reactive-form.ts @@ -0,0 +1,82 @@ +import { gentle_merge } from '../helpers/commons'; + +// this captures a useful pattern where we want to make a reactive form +// that is rooted on "raw data" rather than a Foundry Document +// to access the data from outside it, use the .promise property +// it is resolved when the user clicks submit, and rejected if the form is closed otherwise +// assumptions: +// * the name attributes in the html form directly match the keys in the raw data +// (modulo gentle_merge) +// * the template contains precisely one HTML form as its outermost element +export default abstract class ReactiveForm + extends FormApplication { + declare object: DataModel; + + #resolve: ((data: DataModel) => void) | null = null; + #reject: ((v: void) => void) | null = null; + promise: Promise; + + static get defaultOptions() { + return mergeObject(super.defaultOptions, { + submitOnChange: false, + submitOnClose: false, + closeOnSubmit: true, + }); + } + + constructor(data: DataModel, options: FormApplication.Options) { + super(data, options); + this.promise = new Promise((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + }) + } + + abstract getViewModel(data: DataModel): ViewModel; + + getData(): ViewModel { + let ret: DataModel = this.object; + return this.getViewModel(ret); + } + + // cancel buttons need explicit handling + activateListeners(html: JQuery) { + super.activateListeners(html); + html.find(".cancel").on("click", async (_ev) => { + return this.close(); + }); + } + + async _onChangeInput(_e: JQuery.ChangeEvent) { + await super._onChangeInput(_e); + let data = this._getSubmitData(null); + // TODO: TYPECHECK: are jquery events castable to dom events? + return this._updateObject(_e as unknown as Event, data); + } + + // requires the names in the template to match + _updateObject(ev: Event, formData: any) { + gentle_merge(this.object, formData); + this.render(); + + if (ev.type == "submit") { + if (this.#resolve) { + this.#resolve(this.object); + this.#reject = null; + } + return this.promise; + } else { + return Promise.resolve(this.object); + } + } + + // FormApplication.close() does take an options hash + close(options = {}) { + if (this.#reject) { + this.#reject(); + this.#resolve = null; + }; + // @ts-ignore .8 + return super.close(options); + } +} diff --git a/src/module/helpers/refs.ts b/src/module/helpers/refs.ts index 7737b47e2..6887f1eae 100644 --- a/src/module/helpers/refs.ts +++ b/src/module/helpers/refs.ts @@ -298,7 +298,11 @@ export function editable_mm_ref_list_item( let macroData: LancerMacroData = { iconPath: `systems/${game.system.id}/assets/icons/macro-icons/mech_system.svg`, title: sys.Name, - command: `game.lancer.prepareItemMacro("${sys.Flags.orig_doc.actor?.id ?? ""}", "${sys.Flags.orig_doc.id}")`, + fn: "prepareItemMacro", + args: [ + sys.Flags.orig_doc.actor?.id ?? "", + sys.Flags.orig_doc.id + ] }; let limited = ""; diff --git a/src/module/helpers/slidinghud/SlidingHUDZone.svelte b/src/module/helpers/slidinghud/SlidingHUDZone.svelte new file mode 100644 index 000000000..79056a0c2 --- /dev/null +++ b/src/module/helpers/slidinghud/SlidingHUDZone.svelte @@ -0,0 +1,116 @@ + + + + +
+ {#each visibleHudsKeys as key (key+huds[key].data.title)} +
+ forward(key, "submit", huds[key].data)} + on:cancel={() => forward(key, "cancel")} + /> +
+ {/each} +
+ + diff --git a/src/module/helpers/slidinghud/index.ts b/src/module/helpers/slidinghud/index.ts new file mode 100644 index 000000000..8f704e0e6 --- /dev/null +++ b/src/module/helpers/slidinghud/index.ts @@ -0,0 +1,78 @@ +import type HUDZone from './SlidingHUDZone.svelte'; +import type { AccDiffData } from '../acc_diff'; +import type { LancerActor } from '../../actor/lancer-actor'; + +let hud: typeof HUDZone; + +export async function attach() { + if (!hud) { + let HUDZone = (await import('./SlidingHUDZone.svelte')).default; + hud = new HUDZone({ + target: document.body + }); + } + return hud; +} + +export async function open(key: "hase" | "attack", data: AccDiffData): Promise { + let hud = await attach(); + + // open the hud, cancelling existing listeners + // @ts-ignore + hud.open(key, data); + + return new Promise((resolve, reject) => { + hud.$on(`${key}.submit`, (ev: CustomEvent) => resolve(ev.detail)); + hud.$on(`${key}.cancel`, () => reject()); + }); +} + +// this method differs from open() in two key ways +// 1. it's not allowed to open new windows +// 2. it never allows the caller to observe the data via promise, +// assuming if the window was open that the existing handler is what we want to preserve +export async function refreshTargets(key: "hase" | "attack", ts: Token[] | AccDiffData) { + let hud = await attach(); + + // this method isn't allowed to open new windows, so bail out if it isn't open + // @ts-ignore + if (!hud.isOpen(key)) { return; } + + let { AccDiffData } = await import('../acc_diff'); + + if (ts instanceof AccDiffData) { + // @ts-ignore + return hud.refresh(key, ts); + } + + // @ts-ignore + let oldData: AccDiffData = hud.data(key); + if (!oldData || !(oldData instanceof AccDiffData)) { + throw new Error(`${key} hud is open without valid data: ${oldData}`); + } + let data: AccDiffData = oldData.replaceTargets(ts); + + // @ts-ignore + hud.refresh(key, data); +} + +// this method opens a new window if one isn't open, with as much data as we have, allowing new listeners +// otherwise, it refreshes the existing window, disallowing new listeners +export async function openOrRefresh(key: "hase" | "attack", ts: Token[] | AccDiffData, title: string, actor?: LancerActor,): Promise { + let hud = await attach(); + // @ts-ignore + if (hud.isOpen(key)) { + refreshTargets(key, ts); + return new Promise((_res, rej) => rej()); + } else { + let { AccDiffData } = await import('../acc_diff'); + return open(key, ts instanceof AccDiffData ? ts : + AccDiffData.fromParams(actor, undefined, title, ts, undefined)) + } +} + +export async function fade(dir: "out" | "in" = "out") { + let hud = await attach(); + // @ts-ignore + hud.fade(dir); +} diff --git a/src/module/helpers/slidinghud/sidebar-width.ts b/src/module/helpers/slidinghud/sidebar-width.ts new file mode 100644 index 000000000..b89452168 --- /dev/null +++ b/src/module/helpers/slidinghud/sidebar-width.ts @@ -0,0 +1,10 @@ +import { readable } from 'svelte/store'; + +export const sidebarWidth = readable(0, update => { + const sidebar = $("#sidebar"); + + function setWidth() { update(sidebar.width() || 0); } + + setWidth(); + Hooks.on('collapseSidebar', setWidth); +}); diff --git a/src/module/helpers/svelte-application.ts b/src/module/helpers/svelte-application.ts new file mode 100644 index 000000000..039818ea1 --- /dev/null +++ b/src/module/helpers/svelte-application.ts @@ -0,0 +1,71 @@ +import type SvelteComponent from '*.svelte'; + +type SvelteAppOptions = Application.Options & { + intro?: boolean +} + +export default class SvelteApp extends Application { + klass: typeof SvelteComponent; + data: DataModel; + component?: typeof SvelteComponent; // the type reuses the same type for class and instance + declare options: SvelteAppOptions; + + #resolve: ((data: DataModel) => void) | null = null; + #reject: ((v: void) => void) | null = null; + promise!: Promise; // constructor calls refreshPromise(), which definitely assigns this + + constructor(App: typeof SvelteComponent, data: DataModel, options?: SvelteAppOptions) { + super(options); + this.refreshPromise(); + this.data = data; + this.klass = App; + } + + refreshPromise() { + if (this.#reject) { this.#reject(); } + this.promise = new Promise((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + }) + } + + resolvePromise() { + if (this.#resolve) { + this.#resolve(this.data); + this.#reject = null; + } + } + + rejectPromise() { + if (this.#reject) { + this.#reject(); + this.#resolve = null; + } + } + + activateListeners(html: JQuery) { + let component = new this.klass({ + target: html.get(0), + props: this.data, + intro: !!this.options.intro + }); + component.$on('submit', (_e: Event) => { + this.resolvePromise(); + return this.close(); + }); + component.$on('cancel', (_e: Event) => { + return this.close(); + }); + this.component = component; + } + + close() { + this.rejectPromise() + return super.close(); + } + + async _renderInner(_data: any) { + return $('
'); + } + +} diff --git a/src/module/interfaces.d.ts b/src/module/interfaces.d.ts index 56efd9fb1..be21c3136 100644 --- a/src/module/interfaces.d.ts +++ b/src/module/interfaces.d.ts @@ -48,12 +48,12 @@ declare interface LancerAttackMacroData { grit: number; acc: number; damage: DamageData[]; - overkill: boolean; - effect: EffectData | string; + overkill?: boolean; + effect?: EffectData | string; on_hit?: string; // For NPC weapons - to be removed once they use EffectData tags: TagDataShort[]; - loaded: boolean; - destroyed: boolean; + loaded?: boolean; + destroyed?: boolean; } declare interface LancerTechMacroData { @@ -103,7 +103,8 @@ declare interface LancerOverchargeMacroData { } declare interface LancerMacroData { - command: string; + fn: string; + args: any[]; iconPath?: string; title: string; } diff --git a/src/module/macros.ts b/src/module/macros.ts index 75ead8a8a..be9330ec0 100644 --- a/src/module/macros.ts +++ b/src/module/macros.ts @@ -22,6 +22,7 @@ import { PilotWeapon, MechWeapon, RegDamageData, + RegRef, MechWeaponProfile, NpcFeature, OpCtx, @@ -35,26 +36,49 @@ import { is_ref, resolve_dotpath } from "./helpers/commons"; import { buildActionHTML, buildDeployableHTML, buildSystemHTML } from "./helpers/item"; import { ActivationOptions, StabOptions1, StabOptions2 } from "./enums"; import { applyCollapseListeners, uuid4 } from "./helpers/collapse"; -import { checkForHit, getTargets } from "./helpers/automation/targeting"; -import { AccDiffFlag, tagsToFlags, toggleCover, updateTotals } from "./helpers/acc_diff"; +import { checkForHit } from "./helpers/automation/targeting"; +import type { AccDiffData, AccDiffDataSerialized } from "./helpers/acc_diff"; import { is_overkill } from "machine-mind/dist/funcs"; +import { LancerGame } from "./lancer-game"; const lp = LANCER.log_prefix; -export function encodeMacroData(macroData: LancerMacroData): string { - return btoa(encodeURI(JSON.stringify(macroData))); +const encodedMacroWhitelist = [ + "prepareActivationMacro", + "prepareEncodedAttackMacro", + "prepareTechMacro", + "prepareStatMacro", + "prepareItemMacro", + "prepareCoreActiveMacro", + "prepareStructureSecondaryRollMacro", +]; + +export function encodeMacroData(data: LancerMacroData): string { + return btoa(encodeURI(JSON.stringify(data))); } -export async function runEncodedMacro(el: JQuery) { - let encoded = el.attr("data-macro"); +export async function runEncodedMacro(el: HTMLElement | LancerMacroData) { + let data: LancerMacroData | null = null; - if (!encoded) throw Error("No macro data available"); - let data: LancerMacroData = JSON.parse(decodeURI(atob(encoded))); + if (el instanceof HTMLElement) { + let encoded = el.attributes.getNamedItem('data-macro')?.nodeValue; + if (!encoded) { + console.warn("No macro data available"); + return; + } - let command = data.command; + data = JSON.parse(decodeURI(atob(encoded))) as LancerMacroData; + } else { + data = el as LancerMacroData; + } - // Some might say eval is bad, but it's no worse than what we can already do with macros - eval(command); + if (encodedMacroWhitelist.indexOf(data.fn) < 0) { + console.error("Attempting to call unwhitelisted function via encoded macro: " + data.fn); + return; + } + + let fn = (game as LancerGame).lancer[data.fn]; + return (fn as any).apply(null, data.args) } export async function onHotbarDrop(_bar: any, data: any, slot: number) { @@ -66,9 +90,13 @@ export async function onHotbarDrop(_bar: any, data: any, slot: number) { let img = `systems/${game.system.id}/assets/icons/macro-icons/d20-framed.svg`; // Grab new encoded data ASAP - if (data.command && data.title) { - (command = data.command), - (img = data.iconPath ? data.iconPath : `systems/${game.system.id}/assets/icons/macro-icons/generic_item.svg`); + if (data.fn && data.args && data.title) { // i.e., data instanceof LancerMacroData + if (encodedMacroWhitelist.indexOf(data.fn) < 0) { + ui.notifications!.error("You are trying to drop an invalid macro"); + return; + } + command = `game.lancer.${data.fn}.apply(null, ${JSON.stringify(data.args)})`; + img = data.iconPath ? data.iconPath : `systems/${game.system.id}/assets/icons/macro-icons/generic_item.svg`; title = data.title; } else if (data.pack) { // If we have a source pack, it's dropped from a compendium and there's no processing for us to do @@ -434,26 +462,47 @@ function getMacroActorItem(a: string, i: string): { actor: LancerActor | undefin return result; } -async function buildAttackRollString( - title: string, - flags: AccDiffFlag[], - bonus: number, - starting?: [number, number] // initial [accuracy, difficulty] -): Promise { - let abort: boolean = false; - let acc = 0; - await promptAccDiffModifier(flags, title, starting).then( - resolve => (acc = resolve), - reject => (abort = reject) - ); - if (abort) return null; - - // Do the attack rolling - let acc_str = acc != 0 ? ` + ${acc}d6kh1` : ""; - return `1d20+${bonus}${acc_str}`; +function rollStr(bonus: number, total: number): string { + let modStr = ""; + if (total != 0) { + let sign = total > 0 ? "+" : "-"; + let abs = Math.abs(total); + let roll = abs == 1 ? "1d6" : `${abs}d6kh1`; + modStr = ` ${sign} ${roll}`; + } + return `1d20 + ${bonus}${modStr}`; } -export async function prepareStatMacro(a: string, statKey: string) { +function applyPluginsToRoll(str: string, plugins: { modifyRoll(i: string): string }[]): string { + return plugins.reduce((acc, p) => p.modifyRoll(acc), str); +} + +type AttackRolls = { + roll: string, + targeted: { + target: Token, + roll: string, + usedLockOn: { delete: () => void } | null, + }[] +} + +function attackRolls(bonus: number, accdiff: AccDiffData): AttackRolls { + let perRoll = Object.values(accdiff.weapon.plugins); + let base = perRoll.concat(Object.values(accdiff.base.plugins)); + return { + roll: applyPluginsToRoll(rollStr(bonus, accdiff.base.total), base), + targeted: accdiff.targets.map(tad => { + let perTarget = perRoll.concat(Object.values(tad.plugins)); + return { + target: tad.target, + roll: applyPluginsToRoll(rollStr(bonus, tad.total), perTarget), + usedLockOn: tad.usingLockOn, + } + }) + }; +} + +export async function prepareStatMacro(a: string, statKey: string, rerollData?: AccDiffDataSerialized) { // Determine which Actor to speak as let actor = getMacroSpeaker(a); if (!actor) return; @@ -469,7 +518,12 @@ export async function prepareStatMacro(a: string, statKey: string) { bonus: bonus, }; if (mData.title === "TECHATTACK") { - rollTechMacro(actor, { acc: 0, action: "Quick", t_atk: bonus, effect: "", tags: [], title: "" }); + let partialMacroData = { + title: "Reroll stat macro", + fn: "prepareStatMacro", + args: [a, statKey] + }; + rollTechMacro(actor, { acc: 0, action: "Quick", t_atk: bonus, effect: "", tags: [], title: "" }, partialMacroData, rerollData); } else { rollStatMacro(actor, mData).then(); } @@ -485,13 +539,18 @@ async function rollStatMacro(actor: LancerActor, data: LancerStatMacroData) { if (!actor) return Promise.resolve(); // Get accuracy/difficulty with a prompt - let acc: number = 0; - let abort: boolean = false; - await promptAccDiffModifier().then( - resolve => (acc = resolve), - () => (abort = true) - ); - if (abort) return Promise.resolve(); + let { AccDiffData } = await import('./helpers/acc_diff'); + let initialData = AccDiffData.fromParams(actor, undefined, data.title); + + let promptedData; + try { + let { open } = await import('./helpers/slidinghud'); + promptedData = await open('hase', initialData); + } catch (_e) { + return; + } + + let acc: number = promptedData.base.total; // Do the roll let acc_str = acc != 0 ? ` + ${acc}d6kh1` : ""; @@ -531,6 +590,27 @@ async function rollTalentMacro(actor: LancerActor, data: LancerTalentMacroData) return renderMacroTemplate(actor, template, templateData); } +type AttackMacroOptions = { + accBonus: number; + damBonus: { type: DamageType; val: number }; +} + +export async function prepareEncodedAttackMacro( + actor_ref: RegRef, item_id: string | null, options: AttackMacroOptions, rerollData: AccDiffDataSerialized) { + let reg = new FoundryReg(); + let opCtx = new OpCtx() + let mm = await reg.resolve(opCtx, actor_ref); + let actor = mm.Flags.orig_doc; + let item = item_id ? ownedItemFromString(item_id, actor) : null; + let { AccDiffData } = await import('./helpers/acc_diff'); + let accdiff = AccDiffData.fromObject(rerollData, item ?? actor); + if (item) { + return prepareAttackMacro({ actor, item, options }, accdiff); + } else { + return refreshTargeting("may open new window", accdiff); + } +} + /** * Standalone prepare function for attacks, since they're complex. * @param actor {Actor} Actor to roll as. Assumes properly prepared item. @@ -539,6 +619,7 @@ async function rollTalentMacro(actor: LancerActor, data: LancerTalentMacroData) * - accBonus Flat bonus to accuracy * - damBonus Object of form {type: val} to apply flat damage bonus of given type. * The "Bonus" type is recommended but not required + * @param rerollData {AccDiffData?} saved accdiff data for rerolls */ async function prepareAttackMacro({ actor, @@ -551,7 +632,7 @@ async function prepareAttackMacro({ accBonus: number; damBonus: { type: DamageType; val: number }; }; -}) { +}, rerollData?: AccDiffData) { if (!item.is_npc_feature() && !item.is_mech_weapon() && !item.is_pilot_weapon()) return; let mData: LancerAttackMacroData = { title: item.name ?? "", @@ -598,7 +679,7 @@ async function prepareAttackMacro({ mData.overkill = is_overkill(itemEnt); mData.effect = weaponData.Effect; } else if (actor.is_npc() && item.is_npc_feature()) { - const mm = item.data.data.derived.mm!; + const mm: NpcFeature = await item.data.data.derived.mm_promise; let tier_index: number = mm.TierOverride; if (!mm.TierOverride) { if (item.actor === null) { @@ -676,14 +757,21 @@ async function prepareAttackMacro({ } } - // Build attack string before deducting charge. - const atk_str = await buildAttackRollString( - mData.title, - tagsToFlags(mData.tags), - mData.grit, - mData.acc > 0 ? [mData.acc, 0] : [0, -mData.acc] - ); - if (!atk_str) return; + // Prompt the user before deducting charges. + const targets = Array.from(game!.user!.targets); + let { AccDiffData } = await import('./helpers/acc_diff'); + const initialData = rerollData ?? AccDiffData.fromParams( + item, mData.tags, mData.title, targets, mData.acc > 0 ? [mData.acc, 0] : [0, -mData.acc]); + + let promptedData; + try { + let { open } = await import('./helpers/slidinghud'); + promptedData = await open('attack', initialData); + } catch (_e) { + return; + } + + const atkRolls = attackRolls(mData.grit, promptedData); // Deduct charge if LOADING weapon. if ( @@ -699,44 +787,142 @@ async function prepareAttackMacro({ await itemEnt.writeback(); } - await rollAttackMacro(actor, atk_str, mData); + let rerollMacro = { + title: "Reroll attack", + fn: "prepareEncodedAttackMacro", + args: [ + actor.data.data.derived.mm!.as_ref(), + item.id, + options, + promptedData.toObject() + ] + }; + + await rollAttackMacro(actor, atkRolls, mData, rerollMacro); } -async function rollAttackMacro(actor: LancerActor, atk_str: string | null, data: LancerAttackMacroData) { - if (!atk_str) return; +export async function refreshTargeting( + mode: "may open new window" | "only refresh open window", + data: Token[] | AccDiffData = Array.from(game!.user!.targets) +) { + let { refreshTargets, openOrRefresh } = await import('./helpers/slidinghud'); - // IS SMART? - const isSmart = data.tags.findIndex(tag => tag.Tag.LID === "tg_smart") > -1; - // CHECK TARGETS - const targets = getTargets(); - let hits: { - token: { name: string; img: string }; - total: string; - hit: boolean; - crit: boolean; - }[] = []; - let attacks: { roll: Roll; tt: string }[] = []; - if (game.settings.get(game.system.id, LANCER.setting_automation_attack) && targets.length > 0) { - for (const target of targets) { - let attack_roll = await new Roll(atk_str!).evaluate({ async: true }); + if (mode == "only refresh open window") { + refreshTargets("attack", data); + return; + } + + let actor = getMacroSpeaker(); + + let promptedData; + try { + promptedData = await openOrRefresh("attack", data, "Basic Attack", actor); + } catch (_e) { + return; + } + + actor = actor ?? getMacroSpeaker(); + if (!actor) { + ui.notifications!.error("Can't find unit to attack as. Please select a token."); + return; + } + + let mData = { + title: 'BASIC ATTACK', + grit: 0, + acc: 0, + tags: [], + damage: [], + }; + + + let pilotEnt: Pilot; + if (actor.data.type === EntryType.MECH) { + pilotEnt = (await actor.data.data.derived.mm_promise).Pilot!; + mData.grit = pilotEnt.Grit; + } else if (actor.data.type === EntryType.PILOT) { + pilotEnt = await actor.data.data.derived.mm_promise; + mData.grit = pilotEnt.Grit; + } else if (actor.data.type === EntryType.NPC) { + const mm = await actor.data.data.derived.mm_promise; + let tier_bonus: number = mm.Tier - 1; + mData.grit = tier_bonus || 0; + } else { + ui.notifications!.error(`Error preparing targeting macro - ${actor.name} is an unknown type!`); + return; + } + + const atkRolls = attackRolls(mData.grit, promptedData); + + let rerollMacro = { + title: "Reroll attack", + fn: "prepareEncodedAttackMacro", + args: [ + actor.data.data.derived.mm!.as_ref(), + null, + {}, + promptedData.toObject() + ] + }; + + await rollAttackMacro(actor, atkRolls, mData, rerollMacro); +} + +type AttackResult = { + roll: Roll, + tt: string | HTMLElement | JQuery +} + +type HitResult = { + token: { name: string, img: string }, + total: string, + hit: boolean, + crit: boolean +} + +async function checkTargets(atkRolls: AttackRolls, isSmart: boolean): Promise<{ + attacks: AttackResult[], + hits: HitResult[] +}> { + if (game.settings.get(game.system.id, LANCER.setting_automation_attack) && atkRolls.targeted.length > 0) { + let data = await Promise.all(atkRolls.targeted.map(async targetingData => { + let target = targetingData.target; + let actor = target.actor as LancerActor; + let attack_roll = await new Roll(targetingData.roll).evaluate({ async: true }); const attack_tt = await attack_roll.getTooltip(); - attacks.push({ roll: attack_roll, tt: attack_tt }); - hits.push({ - token: { - name: target.token ? target.token.data.name! : target.data.name, - img: target.token ? target.token.data.img! : target.data.img!, - }, - total: String(attack_roll.total).padStart(2, "0"), - hit: await checkForHit(isSmart, attack_roll, target), - crit: (attack_roll.total ?? 0) >= 20, - }); - } + if (targetingData.usedLockOn) { + targetingData.usedLockOn.delete(); + } + + return { + attack: { roll: attack_roll, tt: attack_tt }, + hit: { + token: { name: target.data.name!, img: target.data.img! }, + total: String(attack_roll.total).padStart(2, "0"), + hit: await checkForHit(isSmart, attack_roll, actor), + crit: (attack_roll.total || 0) >= 20, + } + } + })); + + return { + attacks: data.map(d => d.attack), + hits: data.map(d => d.hit) + }; } else { - let attack_roll = await new Roll(atk_str).evaluate({ async: true }); + let attack_roll = await new Roll(atkRolls.roll).evaluate({ async: true }); const attack_tt = await attack_roll.getTooltip(); - attacks.push({ roll: attack_roll, tt: attack_tt }); + return { + attacks: [{ roll: attack_roll, tt: attack_tt }], + hits: [] + } } +} + +async function rollAttackMacro(actor: LancerActor, atkRolls: AttackRolls, data: LancerAttackMacroData, rerollMacro: LancerMacroData) { + const isSmart = data.tags.findIndex(tag => tag.Tag.LID === "tg_smart") > -1; + const { attacks, hits } = await checkTargets(atkRolls, isSmart); // Iterate through damage types, rolling each let damage_results: Array<{ @@ -871,6 +1057,7 @@ async function rollAttackMacro(actor: LancerActor, atk_str: string | null, data: effect: data.effect ? data.effect : null, on_hit: data.on_hit ? data.on_hit : null, tags: data.tags, + rerollMacroData: encodeMacroData(rerollMacro) }; console.debug(templateData); @@ -992,7 +1179,7 @@ async function rollTextMacro(actor: LancerActor, data: LancerTextMacroData) { return renderMacroTemplate(actor, template, data); } -export async function prepareTechMacro(a: string, t: string) { +export async function prepareTechMacro(a: string, t: string, rerollData?: AccDiffDataSerialized) { // Determine which Actor to speak as let actor = getMacroSpeaker(a); if (!actor) return; @@ -1049,44 +1236,37 @@ export async function prepareTechMacro(a: string, t: string) { } console.log(`${lp} Tech Attack Macro Item:`, item, mData); - await rollTechMacro(actor, mData); + let partialMacroData = { + title: "Reroll tech attack", + fn: "prepareTechMacro", + args: [a, t] + } + + await rollTechMacro(actor, mData, partialMacroData, rerollData, item); } -async function rollTechMacro(actor: LancerActor, data: LancerTechMacroData) { - let atk_str = await buildAttackRollString(data.title, tagsToFlags(data.tags), data.t_atk); - if (!atk_str) return; - - // CHECK TARGETS - const targets = getTargets(); - let hits: { - token: { name: string; img: string }; - total: string; - hit: boolean; - crit: boolean; - }[] = []; - let attacks: { roll: Roll; tt: string }[] = []; - if (game.settings.get(game.system.id, LANCER.setting_automation_attack) && targets.length > 0) { - for (const target of targets) { - let attack_roll = await new Roll(atk_str!).evaluate({ async: true }); - const attack_tt = await attack_roll.getTooltip(); - attacks.push({ roll: attack_roll, tt: attack_tt }); +async function rollTechMacro(actor: LancerActor, data: LancerTechMacroData, partialMacroData: LancerMacroData, rerollData?: AccDiffDataSerialized, item?: LancerItem) { + const targets = Array.from(game!.user!.targets); + let { AccDiffData } = await import('./helpers/acc_diff'); + const initialData = rerollData ? + AccDiffData.fromObject(rerollData, item) : + AccDiffData.fromParams(item, data.tags, data.title, targets); - hits.push({ - token: { - name: target.token ? target.token.data.name! : target.data.name, - img: target.token ? target.token.data.img! : target.data.img!, - }, - total: String(attack_roll.total).padStart(2, "0"), - hit: await checkForHit(true, attack_roll, target), - crit: (attack_roll.total ?? 0) >= 20, - }); - } - } else { - let attack_roll = await new Roll(atk_str).evaluate({ async: true }); - const attack_tt = await attack_roll.getTooltip(); - attacks.push({ roll: attack_roll, tt: attack_tt }); + let promptedData; + try { + let { open } = await import('./helpers/slidinghud'); + promptedData = await open('attack', initialData); + } catch (_e) { + return; } + partialMacroData.args.push(promptedData.toObject()); + + let atkRolls = attackRolls(data.t_atk, promptedData); + if (!atkRolls) return; + + const { attacks, hits } = await checkTargets(atkRolls, true); // true = all tech attacks are "smart" + // Output const templateData = { title: data.title, @@ -1095,72 +1275,13 @@ async function rollTechMacro(actor: LancerActor, data: LancerTechMacroData) { action: data.action, effect: data.effect ? data.effect : null, tags: data.tags, + rerollMacroData: encodeMacroData(partialMacroData), }; const template = `systems/${game.system.id}/templates/chat/tech-attack-card.hbs`; return await renderMacroTemplate(actor, template, templateData); } -export async function promptAccDiffModifier(flags?: AccDiffFlag[], title?: string, starting?: [number, number]) { - let template = await renderTemplate(`systems/${game.system.id}/templates/window/acc_diff.hbs`, {}); - return new Promise((resolve, reject) => { - new Dialog({ - title: title ? `${title} - Accuracy and Difficulty` : "Accuracy and Difficulty", - content: template, - buttons: { - submit: { - icon: '', - label: "Submit", - callback: async _dialog => { - let total = updateTotals(); - console.log(`${lp} Dialog returned a modifier of ${total}d6`); - resolve(total); - }, - }, - cancel: { - icon: '', - label: "Cancel", - callback: async () => { - reject(true); - }, - }, - }, - default: "submit", - render: (_html: any) => { - if (flags) { - for (let flag of flags) { - const ret: HTMLInputElement | null = document.querySelector(`[data-acc="${flag}"],[data-diff="${flag}"]`); - ret && (ret.checked = true); - } - } - - if (flags?.includes("SEEKING")) { - toggleCover(false); - } - updateTotals(); - - if (starting) { - $("#accdiff-other-acc").val(starting[0]); - $("#accdiff-other-diff").val(starting[1]); - updateTotals(); - } - - // LISTENERS - $("[data-acc],[data-diff]").on("click", e => { - if (e.currentTarget.dataset.acc === "SEEKING") { - toggleCover(!(e.currentTarget as HTMLInputElement).checked); - } - updateTotals(); - }); - $(".accdiff-grid button.dec-set").on("click", _e => { - updateTotals(); - }); - }, - close: () => reject(true), - }).render(true); - }); -} - export async function prepareOverchargeMacro(a: string) { // Determine which Actor to speak as let actor = getMacroSpeaker(a); @@ -1224,6 +1345,35 @@ async function rollOverchargeMacro(actor: LancerActor, data: LancerOverchargeMac return renderMacroTemplate(actor, template, templateData); } +export function prepareStructureSecondaryRollMacro(registryId: string) { + // @ts-ignore + let roll = new Roll('1d6').evaluate({ async: false }); + let result = roll.total!; + if (result <= 3) { + prepareTextMacro(registryId, "Destroy Weapons", ` +
+
+
+ ${roll.formula} + ${result} +
+
+
+On a 1–3, all weapons on one mount of your choice are destroyed`); + } else { + prepareTextMacro(registryId, "Destroy Systems", ` +
+
+
+ ${roll.formula} + ${result} +
+
+
+On a 4–6, a system of your choice is destroyed`); + } +} + export async function prepareChargeMacro(a: string) { // Determine which Actor to speak as let mech = getMacroSpeaker(a); @@ -1296,7 +1446,7 @@ export async function prepareStructureMacro(a: string) { await actor.structure(); } -export async function prepareActivationMacro(a: string, i: string, type: ActivationOptions, index: number) { +export async function prepareActivationMacro(a: string, i: string, type: ActivationOptions, index: number, rerollData?: AccDiffDataSerialized) { // Determine which Actor to speak as let actor = getMacroSpeaker(a); if (!actor) return; @@ -1325,7 +1475,12 @@ export async function prepareActivationMacro(a: string, i: string, type: Activat case ActivationType.FullTech: case ActivationType.Invade: case ActivationType.QuickTech: - _prepareTechActionMacro(actorEnt, itemEnt, index); + let partialMacroData = { + title: "Reroll activation", + fn: "prepareActivationMacro", + args: [a, i, type, index] + }; + _prepareTechActionMacro(actorEnt, itemEnt, index, partialMacroData, rerollData); break; default: _prepareTextActionMacro(actorEnt, itemEnt, index); @@ -1348,7 +1503,7 @@ async function _prepareTextActionMacro(actorEnt: Mech, itemEnt: MechSystem | Npc await renderMacroHTML(actorEnt.Flags.orig_doc, buildActionHTML(action, { full: true, tags: itemEnt.Tags })); } -async function _prepareTechActionMacro(actorEnt: Mech, itemEnt: MechSystem | NpcFeature, index: number) { +async function _prepareTechActionMacro(actorEnt: Mech, itemEnt: MechSystem | NpcFeature, index: number, partialMacroData: LancerMacroData, rerollData?: AccDiffDataSerialized) { // Support this later... if (itemEnt.Type !== EntryType.MECH_SYSTEM) return; @@ -1382,7 +1537,7 @@ async function _prepareTechActionMacro(actorEnt: Mech, itemEnt: MechSystem | Npc mData.detail = tData.effect ? tData.effect : ""; } */ - await rollTechMacro(actorEnt.Flags.orig_doc, mData); + await rollTechMacro(actorEnt.Flags.orig_doc, mData, partialMacroData, rerollData); } async function _prepareDeployableMacro(actorEnt: Mech, itemEnt: MechSystem | NpcFeature, index: number) { diff --git a/svelte.config.cjs b/svelte.config.cjs new file mode 100644 index 000000000..85b6ce495 --- /dev/null +++ b/svelte.config.cjs @@ -0,0 +1,7 @@ +const sveltePreprocess = require('svelte-preprocess'); + +module.exports = { + // Consult https://github.com/sveltejs/svelte-preprocess + // for more information about preprocessors + preprocess: sveltePreprocess() +}; diff --git a/tsconfig.json b/tsconfig.json index 5b6947d60..69a6e26bb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,5 @@ { + "extends": "@tsconfig/svelte/tsconfig.json", "compilerOptions": { "target": "esnext", "experimentalDecorators": true, @@ -10,7 +11,8 @@ "ESNext" ], "types": [ - "@league-of-foundry-developers/foundry-vtt-types" + "@league-of-foundry-developers/foundry-vtt-types", + "@pyoner/svelte-types" ], "moduleResolution": "node", "strictNullChecks": true, @@ -18,7 +20,8 @@ "noEmit": true, "strict": true, "useDefineForClassFields": false, + "importsNotUsedAsValues": "remove", "skipLibCheck": true }, - "include": ["./src"] + "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] } diff --git a/vite.config.ts b/vite.config.ts index 8c209104f..fde17f26c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,5 @@ import type { UserConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; import { visualizer } from "rollup-plugin-visualizer"; import checker from "vite-plugin-checker"; const path = require("path"); @@ -48,8 +49,12 @@ const config: UserConfig = { }, }, plugins: [ + svelte({ + configFile: "../svelte.config.cjs", // relative to src/ + }), checker({ typescript: true, + svelte: { root: __dirname }, }), visualizer({ gzipSize: true,