From 45c42822c07e5d1db219c516576ace2425b534e9 Mon Sep 17 00:00:00 2001 From: Carlos Fernandez Date: Thu, 2 Jan 2025 21:25:19 -0500 Subject: [PATCH] Add support for changing prepared quantities from the preparation popout --- .../character/apps/formula-picker/app.svelte | 107 +++++++++-- .../character/apps/formula-picker/app.ts | 77 +++++--- .../actor/character/crafting/ability.ts | 179 +++++++++++------- src/module/actor/character/sheet.ts | 25 +-- src/module/chat-message/helpers.ts | 7 +- .../sheet/components/hover-icon-button.svelte | 3 +- static/lang/en.json | 7 +- 7 files changed, 262 insertions(+), 143 deletions(-) diff --git a/src/module/actor/character/apps/formula-picker/app.svelte b/src/module/actor/character/apps/formula-picker/app.svelte index 998924bc26d..7c9c88bf399 100644 --- a/src/module/actor/character/apps/formula-picker/app.svelte +++ b/src/module/actor/character/apps/formula-picker/app.svelte @@ -5,7 +5,7 @@ import HoverIconButton from "@module/sheet/components/hover-icon-button.svelte"; import { sendItemToChat } from "@module/sheet/helpers.ts"; - const { state: data, actor, ability, searchEngine, onSelect, onDeselect }: FormulaPickerContext = $props(); + const { state: data, actor, ability, mode, searchEngine, onSelect, onDeselect }: FormulaPickerContext = $props(); const openStates: Record = $state({}); let queryText = $state(""); @@ -44,7 +44,11 @@
{game.i18n.format("PF2E.LevelN", { level: section.level })}
    {#each section.formulas as formula (formula.item.id)} -
  1. +
  2. {formula.item.name} - + {#if mode === "prepare"} +
    + + { + ability.setFormulaQuantity( + formula.item.uuid, + Number(event.currentTarget.value || 0), + ); + event.currentTarget.value = formula.quantity || null; + }} + /> + +
    + {:else} + + {/if} span { - font-style: italic; + &.faded { + > *:not(.item-summary) { + opacity: 0.88; + } + + > :global(.item-image img) { + filter: grayscale(0.85); + } } > :global(.item-image) { @@ -140,9 +190,11 @@ cursor: pointer; flex: 1; margin: 0; + &:hover { text-shadow: 0 0 8px var(--color-shadow-primary); } + :global { .tags { padding: 0; @@ -154,6 +206,31 @@ } } + > .quantity { + display: flex; + margin-top: var(--space-6); + button { + width: 1.375rem; + height: 1.375rem; + i { + margin: 0; + } + } + input { + border: none; + width: 3ch; + height: 1.375rem; + padding-left: 0; + padding-right: 0; + text-align: center; + &.selected { + font-weight: 600; + opacity: unset; + color: var(); + } + } + } + > button.select { width: 1.75rem; height: 1.75rem; diff --git a/src/module/actor/character/apps/formula-picker/app.ts b/src/module/actor/character/apps/formula-picker/app.ts index f75dfa9c0b0..a1a59de3e99 100644 --- a/src/module/actor/character/apps/formula-picker/app.ts +++ b/src/module/actor/character/apps/formula-picker/app.ts @@ -1,6 +1,5 @@ import { ActorPF2e } from "@actor/base.ts"; import type { CraftingAbility } from "@actor/character/crafting/ability.ts"; -import { CraftingFormula } from "@actor/character/crafting/types.ts"; import { CharacterPF2e } from "@actor/character/document.ts"; import { ResourceData } from "@actor/creature/index.ts"; import { AbilityItemPF2e, FeatPF2e, PhysicalItemPF2e } from "@item"; @@ -16,11 +15,8 @@ import Root from "./app.svelte"; interface FormulaPickerConfiguration extends ApplicationConfiguration { actor: CharacterPF2e; ability: CraftingAbility; - prompt: string; item?: FeatPF2e | AbilityItemPF2e; - getSelected?: () => ItemUUID[]; - onSelect: SelectFunction; - onDeselect: SelectFunction; + mode: "craft" | "prepare"; } /** Creates a formula picker dialog that resolves with the selected item */ @@ -85,48 +81,71 @@ class FormulaPicker extends SvelteApplicationMixin< } protected override async _prepareContext(): Promise { - const actor = this.options.actor; - const ability = this.options.ability; - const resource = actor.getResource(ability.resource ?? ""); - const selected = this.options.getSelected?.() ?? []; - - const formulas = (await actor.crafting.getFormulas()).filter((f) => ability.canCraft(f.item, { warn: false })); + const { actor, ability, mode } = this.options; + const formulas = await ability.getValidFormulas(); + const sheetData = await ability.getSheetData(); + const resource = sheetData.resource; this.#searchEngine.removeAll(); this.#searchEngine.addAll(formulas.map((f) => R.pick(f.item, ["id", "name"]))); + const prompt = + mode === "prepare" + ? game.i18n.format("PF2E.Actor.Character.Crafting.PrepareHint", { + remaining: sheetData.remainingSlots, + }) + : resource + ? game.i18n.format("PF2E.Actor.Character.Crafting.Action.Hint", { + resource: resource.label, + value: resource.value, + max: resource.max, + }) + : game.i18n.localize("PF2E.Actor.Character.Crafting.Action.HintResourceless"); + return { foundryApp: this, actor, ability, + mode: this.options.mode, onSelect: (uuid: ItemUUID) => { if (this.#resolve) { const item = formulas.find((f) => f.item.uuid === uuid)?.item; this.selection = item ?? null; this.close(); + } else if (mode === "prepare") { + ability.prepareFormula(uuid); } - - this.options.onSelect(uuid, { formulas }); }, onDeselect: (uuid: ItemUUID) => { - this.options.onDeselect(uuid, { formulas }); + if (mode === "prepare") { + ability.unprepareFormula(uuid); + } }, searchEngine: this.#searchEngine, state: { name: this.options.item?.name ?? ability.label, resource, - prompt: this.options.prompt, + prompt, sections: R.pipe( - formulas.map((f) => ({ - item: { - ...R.pick(f.item, ["id", "uuid", "img", "name"]), - type: f.item.type as ItemType, - level: f.item.level, - rarity: f.item.rarity, - traits: f.item.traitChatData(), - }, - batchSize: f.batchSize, - selected: selected.includes(f.item.uuid), - })), + formulas.map((f) => { + const preparedQuantity = + mode === "prepare" + ? ability.preparedFormulaData + .filter((d) => d.uuid === f.item.uuid) + .reduce((sum, v) => sum + (v.quantity ?? 1), 0) + : 0; + + return { + item: { + ...R.pick(f.item, ["id", "uuid", "img", "name"]), + type: f.item.type as ItemType, + level: f.item.level, + rarity: f.item.rarity, + traits: f.item.traitChatData(), + }, + quantity: mode === "prepare" ? preparedQuantity : f.batchSize, + selected: preparedQuantity > 0, + }; + }), R.groupBy((f) => f.item.level || 0), R.entries(), R.map(([level, formulas]): FormulaSection => ({ level: Number(level), formulas })), @@ -140,6 +159,7 @@ class FormulaPicker extends SvelteApplicationMixin< interface FormulaPickerContext extends SvelteApplicationRenderContext { actor: ActorPF2e; ability: CraftingAbility; + mode: "craft" | "prepare"; onSelect: (uuid: ItemUUID) => void; onDeselect: (uuid: ItemUUID) => void; searchEngine: MiniSearch>; @@ -155,7 +175,8 @@ interface FormulaSection { level: number; formulas: { item: FormulaViewData; - batchSize: number; + /** The batch size or quantity prepared depending on context */ + quantity: number; selected: boolean; }[]; } @@ -171,7 +192,5 @@ interface FormulaViewData { rarity: Rarity | null; } -type SelectFunction = (uuid: ItemUUID, options: { formulas: CraftingFormula[] }) => void; - export { FormulaPicker }; export type { FormulaPickerContext }; diff --git a/src/module/actor/character/crafting/ability.ts b/src/module/actor/character/crafting/ability.ts index cceef527341..73293d2ff02 100644 --- a/src/module/actor/character/crafting/ability.ts +++ b/src/module/actor/character/crafting/ability.ts @@ -101,28 +101,7 @@ class CraftingAbility implements CraftingAbilityData { return this.#preparedFormulas; } - async getSheetData(): Promise { - const preparedCraftingFormulas = await this.getPreparedCraftingFormulas(); - const prepared = [...preparedCraftingFormulas]; - const consumed = prepared.reduce((sum, p) => sum + p.batches, 0); - const remainingSlots = Math.max(0, this.maxSlots - consumed); - - return { - label: this.label, - slug: this.slug, - isAlchemical: this.isAlchemical, - isPrepared: this.isPrepared, - isDailyPrep: this.isDailyPrep, - insufficient: this.maxSlots > 0 && consumed > this.maxSlots, - maxItemLevel: this.maxItemLevel, - resource: this.resource ? this.actor.getResource(this.resource) : null, - resourceCost: await this.calculateResourceCost(), - maxSlots: this.maxSlots, - prepared, - remainingSlots, - }; - } - + /** Calculates the resources needed to craft all prepared crafting items */ async calculateResourceCost(): Promise { if (!this.resource) return 0; @@ -163,65 +142,100 @@ class CraftingAbility implements CraftingAbilityData { return true; } - async prepareFormula(formula: CraftingFormula): Promise { - const prepared = await this.getPreparedCraftingFormulas(); - const consumed = prepared.reduce((sum, p) => sum + p.batches, 0); - if (!this.resource && consumed >= this.maxSlots) { - ui.notifications.warn(game.i18n.localize("PF2E.CraftingTab.Alerts.MaxSlots")); - return; - } + /** Returns all items that this ability can craft including the batch size produced by this ability */ + async getValidFormulas(): Promise { + const actorFormulas = await this.actor.crafting.getFormulas(); + const validFormulas = actorFormulas.filter((f) => this.canCraft(f.item, { warn: false })); + return Promise.all(validFormulas.map(async (f) => ({ ...f, batchSize: await this.#batchSizeFor(f) }))); + } - if (!this.canCraft(formula.item)) { - return; - } + async prepareFormula(uuid: string): Promise { + await this.setFormulaQuantity(uuid, "increase"); + } - const quantity = await this.#batchSizeFor(formula); - const existingIdx = this.preparedFormulaData.findIndex((f) => f.uuid === formula.uuid); - if (existingIdx > -1 && (this.resource || this.isDailyPrep)) { - return this.setFormulaQuantity(existingIdx, "increase"); - } else { - this.preparedFormulaData.push({ uuid: formula.uuid, quantity }); + async unprepareFormula(indexOrUuid: number | string): Promise { + if (typeof indexOrUuid === "string") { + this.preparedFormulaData = this.preparedFormulaData.filter((p) => p.uuid !== indexOrUuid); + return this.#updateRuleElement(); } + this.preparedFormulaData.splice(indexOrUuid, 1); return this.#updateRuleElement(); } - async unprepareFormula(indexOrUuid: number | ItemUUID): Promise { + /** Sets a formula's prepared quantity to a specific value, preparing it if necessary */ + async setFormulaQuantity(indexOrUuid: number | string, value: "increase" | "decrease" | number): Promise { const index = typeof indexOrUuid === "number" ? indexOrUuid : this.preparedFormulaData.findIndex((d) => d.uuid === indexOrUuid); - const formula = this.preparedFormulaData[index]; - if (!formula) return; - this.preparedFormulaData.splice(index, 1); - return this.#updateRuleElement(); - } + const data: PreparedFormulaData | null = this.preparedFormulaData[index] ?? null; + if (!data && typeof indexOrUuid !== "string") return; - async setFormulaQuantity(index: number, value: "increase" | "decrease" | number): Promise { - const data = this.preparedFormulaData[index]; - if (!data) return; - - // Make sure we can increase first - if (value === "increase" || (typeof value === "number" && value > 0)) { - const prepared = await this.getPreparedCraftingFormulas(); - const consumed = prepared.reduce((sum, p) => sum + p.batches, 0); - if (this.maxSlots && consumed >= this.maxSlots) { - return; - } + const prepared = await this.getPreparedCraftingFormulas(); + const consumed = prepared.reduce((sum, p) => sum + p.batches, 0); + const itemUuid = data?.uuid ?? (typeof indexOrUuid === "string" ? indexOrUuid : null); + const item = itemUuid ? await fromUuid(itemUuid) : null; + const batchSize = + this.fieldDiscovery?.test(item?.getRollOptions("item") ?? []) || !item ? 1 : await this.#batchSizeFor(item); + const individualPrep = !this.isDailyPrep; // Delayed prep (and eventually snares in general?) need to be one at a time + const currentQuantity = individualPrep + ? prepared.filter((p) => p.uuid === item?.uuid).length + : (data?.quantity ?? 0); + if (!itemUuid) return; + + // Determine if we're maxed out, if so, exit with a warning + const increasing = value === "increase" || !data || (typeof value === "number" && value > currentQuantity); + if (!this.resource && consumed >= this.maxSlots && increasing) { + ui.notifications.warn(game.i18n.localize("PF2E.CraftingTab.Alerts.MaxSlots")); + return; } - const currentQuantity = data.quantity ?? 0; - const item = this.fieldDiscovery ? await fromUuid(data.uuid) : null; - const adjustment = this.fieldDiscovery?.test(item?.getRollOptions("item") ?? []) - ? 1 - : await this.#batchSizeFor(data); - const newQuantity = + // Calculate new quantity. Exit early if we're increasing and there is no item + // If we're decreasing, we still need to be able to do so even if the item doesn't exist + const validMaxQuantity = this.maxSlots ? currentQuantity + (this.maxSlots - consumed) * batchSize : Infinity; + const newQuantity = Math.clamp( typeof value === "number" ? value : value === "increase" - ? currentQuantity + adjustment - : currentQuantity - adjustment; - data.quantity = Math.ceil(Math.clamp(newQuantity, adjustment, adjustment * 50) / adjustment) * adjustment; + ? currentQuantity + batchSize + : currentQuantity - batchSize, + 0, + validMaxQuantity, + ); + if (newQuantity > currentQuantity && (!item?.isOfType("physical") || !this.canCraft(item))) { + return; + } + + // If quantity is being set to zero, unprepare the item + if (newQuantity === 0) { + return this.unprepareFormula(itemUuid ?? index); + } + + // Handle individual prep items, which must be prepared individually without quantity + if (individualPrep) { + if (newQuantity > currentQuantity) { + if (!item) return; + this.preparedFormulaData.push( + ...R.range(0, newQuantity - currentQuantity).map(() => ({ uuid: item.uuid })), + ); + } else { + for (let i = 0; i < currentQuantity - newQuantity; i++) { + const idx = this.preparedFormulaData.findLastIndex((p) => p.uuid === itemUuid); + this.preparedFormulaData.splice(idx, 1); + } + } + + return this.#updateRuleElement(); + } + + // Create a new prepared entry if it doesn't exist, otherwise update an existing one + if (!data) { + if (!item) return; + this.preparedFormulaData.push({ uuid: item.uuid, quantity: newQuantity }); + } else { + data.quantity = Math.ceil(Math.clamp(newQuantity, batchSize, batchSize * 50) / batchSize) * batchSize; + } return this.#updateRuleElement(); } @@ -367,13 +381,40 @@ class CraftingAbility implements CraftingAbilityData { }; } - async #batchSizeFor(data: CraftingFormula | PreparedFormulaData): Promise { - const knownFormulas = await this.actor.crafting.getFormulas(); - const formula = knownFormulas.find((f) => f.item.uuid === data.uuid); - if (!formula) return 1; + async getSheetData(): Promise { + const preparedCraftingFormulas = await this.getPreparedCraftingFormulas(); + const prepared = [...preparedCraftingFormulas]; + const consumed = prepared.reduce((sum, p) => sum + p.batches, 0); + const remainingSlots = Math.max(0, this.maxSlots - consumed); - const rollOptions = formula.item.getRollOptions("item"); + return { + label: this.label, + slug: this.slug, + isAlchemical: this.isAlchemical, + isPrepared: this.isPrepared, + isDailyPrep: this.isDailyPrep, + insufficient: this.maxSlots > 0 && consumed > this.maxSlots, + maxItemLevel: this.maxItemLevel, + resource: this.resource ? this.actor.getResource(this.resource) : null, + resourceCost: await this.calculateResourceCost(), + maxSlots: this.maxSlots, + prepared, + remainingSlots, + }; + } + + /** Helper to return the batch size for a formula or item. Once signature item is gone, we can make it take only physical items */ + async #batchSizeFor(data: CraftingFormula | PreparedFormulaData | ItemPF2e): Promise { const isSignatureItem = "isSignatureItem" in data && !!data.isSignatureItem; + const item = + data instanceof ItemPF2e + ? data + : "item" in data + ? data.item + : (await this.actor.crafting.getFormulas()).find((f) => f.item.uuid === data.uuid)?.item; + if (!item) return 1; + + const rollOptions = item.getRollOptions("item"); if (isSignatureItem || this.fieldDiscovery?.test(rollOptions)) { return this.fieldDiscoveryBatchSize; } diff --git a/src/module/actor/character/sheet.ts b/src/module/actor/character/sheet.ts index 69cabd63f00..89e8b9e49c7 100644 --- a/src/module/actor/character/sheet.ts +++ b/src/module/actor/character/sheet.ts @@ -944,26 +944,10 @@ class CharacterSheetPF2e extends CreatureSheetPF2e }; handlers["prepare-formula"] = (_, anchor) => { + const actor = this.actor; const abilitySlug = htmlClosest(anchor, "[data-ability]")?.dataset.ability; - const ability = this.actor.crafting.abilities.get(abilitySlug ?? "", { strict: true }); - - new FormulaPicker({ - actor: this.actor, - ability, - prompt: game.i18n.localize("PF2E.Actor.Character.Crafting.PrepareHint"), - getSelected: () => { - return R.unique(ability.preparedFormulaData.map((d) => d.uuid)); - }, - onSelect: (uuid: ItemUUID, { formulas }) => { - const formula = formulas.find((f) => f.uuid === uuid); - if (formula) { - ability.prepareFormula(formula); - } - }, - onDeselect: (uuid: ItemUUID) => { - ability.unprepareFormula(uuid); - }, - }).render(true); + const ability = actor.crafting.abilities.get(abilitySlug ?? "", { strict: true }); + new FormulaPicker({ actor, ability, mode: "prepare" }).render(true); }; handlers["craft-item"] = async (event, anchor) => { @@ -1416,8 +1400,7 @@ class CharacterSheetPF2e extends CreatureSheetPF2e const ability = this.actor.crafting.abilities.get(slug); if (!ability) return; - const formula = this.#knownFormulas[String(dropData.uuid ?? "")]; - if (formula) ability.prepareFormula(formula); + ability.prepareFormula(String(dropData.uuid ?? "")); return; } } diff --git a/src/module/chat-message/helpers.ts b/src/module/chat-message/helpers.ts index 182b20c9689..12336b57b17 100644 --- a/src/module/chat-message/helpers.ts +++ b/src/module/chat-message/helpers.ts @@ -33,13 +33,8 @@ async function createUseActionMessage( const consumeResources = !!resource?.value; const craftedItem = await (async () => { if (!isCraftingAction) return null; - const prompt = game.i18n.format("PF2E.Actor.Character.Crafting.Action.Hint", { - resource: resource.label, - value: resource.value, - max: resource.max, - }); - const picker = new FormulaPicker({ actor, item, prompt, ability: craftingAbility }); + const picker = new FormulaPicker({ actor, item, ability: craftingAbility, mode: "craft" }); const selection = await picker.resolveSelection(); return selection ? craftingAbility.craft(selection, { diff --git a/src/module/sheet/components/hover-icon-button.svelte b/src/module/sheet/components/hover-icon-button.svelte index 374f99e7423..e245ee63b82 100644 --- a/src/module/sheet/components/hover-icon-button.svelte +++ b/src/module/sheet/components/hover-icon-button.svelte @@ -42,7 +42,8 @@ color: var(--text-dark); } - &:hover { + &:hover, + &:focus { text-shadow: none; i { diff --git a/static/lang/en.json b/static/lang/en.json index fbf1ada266a..c12f8dd311e 100644 --- a/static/lang/en.json +++ b/static/lang/en.json @@ -210,7 +210,8 @@ }, "Crafting": { "Action": { - "Hint": "Select an item to craft using {resource} ({value}/{max})" + "Hint": "Select an item to craft using {resource} ({value}/{max})", + "HintResourceless": "Select an item to craft" }, "BatchQuantity": "x{quantity}", "Cost": "Cost", @@ -219,10 +220,12 @@ "Complete": "Daily crafting complete.", "Perform": "Perform Daily Crafting" }, + "DecreaseQuantity": "Decrease Quantity", "FreeCrafting": "Free Crafting", "FreeCraftingTooltip": "If enabled, creates items immediately without spending gold", + "IncreaseQuantity": "Increase Quantity", "MissingResource": "You have insufficient resources to complete crafting.", - "PrepareHint": "Select the items that you want to prepare", + "PrepareHint": "Select the items that you want to prepare ({remaining} remaining)", "RemainingSlots": "Empty Slot ({remaining} remaining)", "Search": "Search Formulas" },