From 87fcde3aef2db3c92de86f968832cb15bbd8c75e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:10:51 +0000 Subject: [PATCH 1/7] Initial plan From 4dadbb192df9b5690aee624f81b0eecf6d38ec1e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:27:17 +0000 Subject: [PATCH 2/7] feat: add enchanting bench and scroll-based gear enchants Agent-Logs-Url: https://github.com/PJensen/JSHack/sessions/53e9ee15-c468-4fcc-b0bf-a96a2a2354f1 Co-authored-by: PJensen <54164+PJensen@users.noreply.github.com> --- src/display/palette/base.js | 1 + src/display/palette/index.js | 2 +- src/display/ui/enchantingBenchOverlay.js | 180 +++++++++++++++ src/display/ui/inventoryOverlay.js | 7 + src/display/ui/inventoryUtils.js | 4 +- src/display/ui/overlay.js | 53 +++++ .../ui/wiring/messages/economyMessages.js | 40 ++++ src/main.js | 10 + src/main/input/rulesDispatch.js | 9 + src/main/wiring/enchantingWiring.js | 63 +++++ src/rules/archetypes/Overworld.js | 9 + src/rules/content/enchanting/benchGame.js | 215 ++++++++++++++++++ .../content/interaction/interactPayloads.js | 17 ++ src/rules/data/buildings/apothecary.js | 1 + src/rules/data/itemCatalogMagic.js | 136 +++++++++++ src/rules/environment/dungeon/populate.js | 3 +- tests/enchantingBench.test.mjs | 99 ++++++++ tests/inventoryDefaultAction.test.mjs | 11 + tests/rulesDispatchEnchanting.test.mjs | 40 ++++ 19 files changed, 897 insertions(+), 3 deletions(-) create mode 100644 src/display/ui/enchantingBenchOverlay.js create mode 100644 src/main/wiring/enchantingWiring.js create mode 100644 src/rules/content/enchanting/benchGame.js create mode 100644 tests/enchantingBench.test.mjs create mode 100644 tests/rulesDispatchEnchanting.test.mjs diff --git a/src/display/palette/base.js b/src/display/palette/base.js index a23b3009..e5284011 100644 --- a/src/display/palette/base.js +++ b/src/display/palette/base.js @@ -304,6 +304,7 @@ export const basePalette = { house_sign: { glyph: "!", fg: "#d8c08a", glow: "#8b6f3f" }, audio_sign: { glyph: "!", fg: "#ffe066", glow: "#ccaa00" }, alchemy_bench: { glyph: "⚗", fg: "#93def6", glow: "#4f7fa1" }, + enchanting_bench: { glyph: "✧", fg: "#d8b8ff", glow: "#7f5ac8" }, potion_shelf: { glyph: "=", fg: "#7986cb", glow: "#3949ab" }, herb_chest: { glyph: "]", fg: "#66bb6a", glow: "#2e7d32" }, tavern_chest: { glyph: "]", fg: "#d7a15d", glow: "#8f5225" }, diff --git a/src/display/palette/index.js b/src/display/palette/index.js index 7510801b..716267f3 100644 --- a/src/display/palette/index.js +++ b/src/display/palette/index.js @@ -14,7 +14,7 @@ const _CORPSE_SKIP_PREFIXES = [ const _CORPSE_SKIP_KEYS = new Set([ 'gold', 'monster', 'bone', 'engraving', 'spawner', 'tombstone', 'chest', 'mill_chest', 'smithy_chest', 'lumber_chest', 'herb_chest', 'tavern_chest', - 'bed_home', 'house_sign', 'alchemy_bench', + 'bed_home', 'house_sign', 'alchemy_bench', 'enchanting_bench', 'berry_bush', 'herb_patch', 'thorn_bramble', 'venom_fern', 'moonleaf_cluster', 'ember_root_patch', 'venom_spores', 'anvil', 'anvil_active', 'furnace', 'furnace_unlit', 'cooking_fire', 'crop_wheat', 'crop_carrot', 'crop_corn', diff --git a/src/display/ui/enchantingBenchOverlay.js b/src/display/ui/enchantingBenchOverlay.js new file mode 100644 index 00000000..ee41e126 --- /dev/null +++ b/src/display/ui/enchantingBenchOverlay.js @@ -0,0 +1,180 @@ +const INGREDIENT_LABELS = Object.freeze({ + emberRoot: "Ember Root", + moonleaf: "Moonleaf", + thornPods: "Thorn Pods", + venomFronds: "Venom Fronds", + oil: "Flask of Oil", + water: "Water Flask", + gold: "Gold", +}); + +function ingredientLabel(key) { + return INGREDIENT_LABELS[key] || key; +} + +function normalizeRequirements(recipe) { + const req = {}; + const src = (recipe?.requirements && typeof recipe.requirements === "object") ? recipe.requirements : {}; + for (const [key, raw] of Object.entries(src)) { + const need = Math.max(0, Number(raw || 0) | 0); + if (need > 0) req[key] = need; + } + return req; +} + +function canCraftRecipe(recipe, ingredients) { + if (typeof recipe?.canCraft === "boolean") return recipe.canCraft; + const req = normalizeRequirements(recipe); + for (const [key, need] of Object.entries(req)) { + const have = Math.max(0, Number(ingredients?.[key] || 0) | 0); + if (have < need) return false; + } + return true; +} + +function formatRequirementLine(requirements, outputLabel) { + const parts = []; + for (const [key, need] of Object.entries(requirements)) { + parts.push(`${need} ${ingredientLabel(key).toLowerCase()}`); + } + return `Needs ${parts.join(" + ")} → ${outputLabel}`; +} + +export function renderEnchantingBench(panel, state) { + const el = /** @type {HTMLDivElement} */ (/** @type {any} */ (panel)._inner); + el.innerHTML = ""; + + const title = document.createElement("div"); + title.textContent = "✧ Enchanting Bench"; + Object.assign(title.style, { + fontWeight: "bold", + marginBottom: "8px", + color: "#d9c3ff", + fontSize: "18px", + }); + el.appendChild(title); + + const subtitle = document.createElement("div"); + subtitle.textContent = "Bind reagents and gold into a scroll, then apply it to your gear."; + Object.assign(subtitle.style, { + opacity: "0.86", + marginBottom: "10px", + fontSize: "12px", + }); + el.appendChild(subtitle); + + const ingredients = (state?.ingredients && typeof state.ingredients === "object") + ? state.ingredients + : {}; + const stock = document.createElement("div"); + Object.assign(stock.style, { + display: "flex", + flexWrap: "wrap", + gap: "12px", + marginBottom: "10px", + padding: "8px", + border: "1px solid #4f3b67", + borderRadius: "6px", + background: "#16111e", + }); + for (const key of Object.keys(INGREDIENT_LABELS)) { + const item = document.createElement("div"); + const count = Math.max(0, Number(ingredients[key] || 0) | 0); + item.textContent = `${ingredientLabel(key)}: ${count}`; + stock.appendChild(item); + } + el.appendChild(stock); + + const list = document.createElement("div"); + Object.assign(list.style, { + display: "flex", + flexDirection: "column", + gap: "8px", + }); + el.appendChild(list); + + const recipes = Array.isArray(state?.recipes) ? state.recipes : []; + if (!recipes.length) { + const empty = document.createElement("div"); + empty.textContent = "The vellum waits for a worthy binding."; + empty.style.opacity = "0.7"; + list.appendChild(empty); + return; + } + + for (const recipe of recipes) { + const row = document.createElement("div"); + Object.assign(row.style, { + display: "grid", + gridTemplateColumns: "1fr auto", + gap: "8px", + alignItems: "center", + border: "1px solid #4f3b67", + borderRadius: "6px", + padding: "8px", + background: "#130f1c", + }); + + const textWrap = document.createElement("div"); + const label = document.createElement("div"); + label.textContent = String(recipe?.label || "Recipe"); + label.style.fontWeight = "bold"; + label.style.color = "#f1e1ff"; + + const req = document.createElement("div"); + req.textContent = formatRequirementLine(normalizeRequirements(recipe), String(recipe?.outputName || "scroll")); + req.style.opacity = "0.78"; + req.style.fontSize = "12px"; + + const effect = document.createElement("div"); + effect.textContent = String(recipe?.effectSummary || ""); + effect.style.opacity = "0.72"; + effect.style.fontSize = "11px"; + + const flavor = document.createElement("div"); + flavor.textContent = String(recipe?.flavor || ""); + flavor.style.opacity = "0.58"; + flavor.style.fontSize = "11px"; + + textWrap.appendChild(label); + textWrap.appendChild(req); + if (String(recipe?.effectSummary || "").trim()) textWrap.appendChild(effect); + if (String(recipe?.flavor || "").trim()) textWrap.appendChild(flavor); + + const craftBtn = document.createElement("button"); + const canCraft = canCraftRecipe(recipe, ingredients); + craftBtn.textContent = canCraft ? "Scribe" : "Missing"; + craftBtn.disabled = !canCraft || !(state?.benchId > 0); + Object.assign(craftBtn.style, { + minWidth: "92px", + height: "34px", + border: "1px solid #5b4480", + borderRadius: "6px", + background: canCraft ? "#2d1b4f" : "#1a1a1a", + color: canCraft ? "#f1d7ff" : "#8892a0", + fontWeight: "bold", + cursor: canCraft ? "pointer" : "default", + }); + craftBtn.addEventListener("click", () => { + const key = String(recipe?.key || ""); + if (!key || !(state?.benchId > 0)) return; + window.dispatchEvent(new CustomEvent("ui:requestCraftEnchant", { + detail: { benchId: state.benchId, recipe: key }, + })); + }); + + row.appendChild(textWrap); + row.appendChild(craftBtn); + list.appendChild(row); + } + + const hint = document.createElement("div"); + hint.textContent = "Esc closes the bench."; + Object.assign(hint.style, { + marginTop: "10px", + opacity: "0.6", + fontSize: "11px", + textAlign: "center", + }); + el.appendChild(hint); +} diff --git a/src/display/ui/inventoryOverlay.js b/src/display/ui/inventoryOverlay.js index 51350e3f..035ae856 100644 --- a/src/display/ui/inventoryOverlay.js +++ b/src/display/ui/inventoryOverlay.js @@ -431,6 +431,13 @@ export function renderInventory(panel, items, ground, slotFilter = '', scrollOfI return; } if (actionKey === 'use') { + const redirectsToApply = !!it?.canApply + && Number(it?.applyTargetCount || 0) > 0 + && (it?.type === 'scroll' || it?.type === 'tool' || it?.type === 'utility'); + if (redirectsToApply) { + triggerApplyForTool(it); + return; + } if (!isInventoryItemUsable(it) || !Number.isInteger(it.id) || it.id <= 0) return; signalPulse(); pulseRow(row, 'use', restoreBg); if (it.type === 'potion') { diff --git a/src/display/ui/inventoryUtils.js b/src/display/ui/inventoryUtils.js index 713a61f5..ff12d521 100644 --- a/src/display/ui/inventoryUtils.js +++ b/src/display/ui/inventoryUtils.js @@ -32,9 +32,11 @@ export function isInventoryItemUsable(it) { */ export function getInventoryDefaultAction(it) { if (!it) return 'none'; + const canApply = !!it.canApply && Number(it.applyTargetCount || 0) > 0; if (isInventoryItemEquippable(it)) return 'equip'; + if (canApply && (it.type === 'scroll' || it.type === 'tool' || it.type === 'utility')) return 'apply'; if (isInventoryItemUsable(it)) return 'use'; - if (it.canApply && Number(it.applyTargetCount || 0) > 0) return 'apply'; + if (canApply) return 'apply'; if (it.type === 'spell') return 'set-spell'; return 'none'; } diff --git a/src/display/ui/overlay.js b/src/display/ui/overlay.js index 9e8a6a6c..61bd4ab2 100644 --- a/src/display/ui/overlay.js +++ b/src/display/ui/overlay.js @@ -6,6 +6,7 @@ import { createDebugGraph } from './debugGraph.js'; import { createTileInspector } from './tileInspector.js'; import { renderAlchemyBench } from './alchemyBenchOverlay.js'; import { renderAnvil } from './anvilOverlay.js'; +import { renderEnchantingBench } from './enchantingBenchOverlay.js'; import { renderCookingFire } from './cookingFireOverlay.js'; import { renderDialog } from './dialogOverlay.js'; import { playDeathJingle } from '../fx/deathJingle.js'; @@ -783,6 +784,58 @@ export function initOverlays() { renderAlchemyBench(alchemy, _alchemyState); }); + // Enchanting bench overlay + let _enchantingState = { + benchId: 0, + ingredients: { emberRoot: 0, moonleaf: 0, thornPods: 0, venomFronds: 0, oil: 0, water: 0, gold: 0 }, + recipes: [], + }; + window.addEventListener('ui:openEnchantingBench', (ev) => { + /** @type {CustomEvent} */ // @ts-ignore + const e = ev; + const d = e?.detail || {}; + _craftPanelMode = 'enchanting'; + _enchantingState.benchId = Number(d.benchId || 0) | 0; + show(alchemy); + renderEnchantingBench(alchemy, _enchantingState); + const escKey = (/** @type {KeyboardEvent} */ ke) => { + if (alchemy.style.display !== 'block') return; + if (_craftPanelMode !== 'enchanting') return; + if (ke.key === 'Escape') { + window.dispatchEvent(new CustomEvent('ui:closeEnchantingBench')); + ke.preventDefault(); + } + }; + window.addEventListener('keydown', escKey); + const obs = new MutationObserver(() => { + if (alchemy.style.display === 'none') { + window.removeEventListener('keydown', escKey); + obs.disconnect(); + } + }); + obs.observe(alchemy, { attributes: true, attributeFilter: ['style'] }); + }); + window.addEventListener('ui:closeEnchantingBench', () => { + _enchantingState.benchId = 0; + if (_craftPanelMode === 'enchanting') { + _craftPanelMode = ''; + hide(alchemy); + } + }); + window.addEventListener('ui:enchantingBenchData', (ev) => { + /** @type {CustomEvent} */ // @ts-ignore + const e = ev; + const d = e?.detail || {}; + _enchantingState = { + benchId: Number(d.benchId || _enchantingState.benchId || 0) | 0, + ingredients: d.ingredients && typeof d.ingredients === 'object' + ? d.ingredients + : { emberRoot: 0, moonleaf: 0, thornPods: 0, venomFronds: 0, oil: 0, water: 0, gold: 0 }, + recipes: Array.isArray(d.recipes) ? d.recipes : [], + }; + renderEnchantingBench(alchemy, _enchantingState); + }); + // Anvil overlay let _anvilState = { anvilId: 0, diff --git a/src/display/ui/wiring/messages/economyMessages.js b/src/display/ui/wiring/messages/economyMessages.js index 3b6b98be..f023ae1a 100644 --- a/src/display/ui/wiring/messages/economyMessages.js +++ b/src/display/ui/wiring/messages/economyMessages.js @@ -7,6 +7,24 @@ export function installEconomyMessages(ctx) { compGet, canSeeAt, formatBulletinDistrictLine, formatBulletinRumors, formatIngredientBag, harvestYieldLabel, harvestNodeLabel, isOreKind, BULLETIN_SECTOR_LABELS, Position } = ctx; + const formatEnchantingBag = (bag, { includeZero = false } = {}) => { + const labels = { + emberRoot: 'ember root', + moonleaf: 'moonleaf', + thornPods: 'thorn pods', + venomFronds: 'venom fronds', + oil: 'oil', + water: 'water', + gold: 'gold', + }; + const parts = []; + for (const [key, label] of Object.entries(labels)) { + const count = Math.max(0, Number(bag?.[key] || 0) | 0); + if (!includeZero && count <= 0) continue; + parts.push(`${count} ${label}`); + } + return parts.join(', '); + }; world.on('town:bulletinBoard', ({ actor, districts, opportunityView, questBoard }) => { if (nameOfEntity(actor) !== 'You') return; @@ -133,6 +151,28 @@ export function installEconomyMessages(ctx) { if (result === 'brew_failed') log('The brew collapses into sludge.', 'system'); }); + // === Enchanting events === + world.on('enchanting:open', ({ actor, ingredients }) => { + if (nameOfEntity(actor) !== 'You') return; + log(`You open the enchanting bench. (${formatEnchantingBag(ingredients, { includeZero: true }) || "no stock"})`, 'system'); + }); + + world.on('enchanting:crafted', ({ actor, recipeLabel, outputName }) => { + if (nameOfEntity(actor) !== 'You') return; + log(`You scribe ${bracketizeName(String(recipeLabel || 'an enchantment'))} and receive ${bracketizeName(String(outputName || 'a scroll'))}.`, 'system'); + }); + + world.on('enchanting:result', ({ actor, result, missing, recipeKey }) => { + if (nameOfEntity(actor) !== 'You') return; + if (result === 'missing_requirements') { + log(`Missing materials for ${recipeKey || 'that enchant'}: ${formatEnchantingBag(missing) || "requirements not met"}.`, 'system'); + return; + } + if (result === 'unknown_recipe') { log('That enchantment recipe is unknown.', 'system'); return; } + if (result === 'no_inventory') { log('You need an inventory to carry the finished scroll.', 'system'); return; } + if (result === 'craft_failed') log('The glyph buckles and the enchantment fails to take hold.', 'system'); + }); + // === Mill events === world.on('mill:milled', ({ actor }) => { if (nameOfEntity(actor) !== 'You') return; diff --git a/src/main.js b/src/main.js index 57ca3174..3a3d7910 100644 --- a/src/main.js +++ b/src/main.js @@ -81,6 +81,7 @@ import { installRackWiring } from "./main/wiring/rackWiring.js"; import { installAlchemyWiring } from "./main/wiring/alchemyWiring.js"; import { installAnvilWiring } from "./main/wiring/anvilWiring.js"; import { installCookingWiring } from "./main/wiring/cookingWiring.js"; +import { installEnchantingWiring } from "./main/wiring/enchantingWiring.js"; import { installDigWiring } from "./main/wiring/digWiring.js"; import { installDialogWiring } from "./main/wiring/dialogWiring.js"; import { installSpeechBubbleWiring } from "./main/wiring/speechBubbleWiring.js"; @@ -1839,6 +1840,15 @@ installAlchemyWiring({ }, log: (msg) => messageLog.log({ text: msg, type: "system" }), }); +installEnchantingWiring({ + world, + playerEntity, + dispatchRules: (action) => { + const rulesHandler = makeRulesDispatcher(world, () => (playerEntity(world)?.id || 0)); + rulesHandler(action); + }, + log: (msg) => messageLog.log({ text: msg, type: "system" }), +}); installAnvilWiring({ world, playerEntity, diff --git a/src/main/input/rulesDispatch.js b/src/main/input/rulesDispatch.js index 9ad8b5f7..319622b5 100644 --- a/src/main/input/rulesDispatch.js +++ b/src/main/input/rulesDispatch.js @@ -431,6 +431,15 @@ export function makeRulesDispatcher(world, getActorId, opts = {}) { world?.tick?.(1); break; } + case "rules.craftEnchant": { + const { benchId = 0, recipe = "" } = action.payload || {}; + if (!Number.isInteger(benchId) || benchId <= 0) break; + const recipeKey = String(recipe || "").trim().toLowerCase(); + if (!recipeKey) break; + world?.add?.(actorId, InteractIntent, { targetId: benchId, mode: "enchant", recipe: recipeKey }); + world?.tick?.(1); + break; + } case "rules.cookFood": { const { fireId = 0, itemId: cookItemId = 0 } = action.payload || {}; if (!Number.isInteger(fireId) || fireId <= 0) break; diff --git a/src/main/wiring/enchantingWiring.js b/src/main/wiring/enchantingWiring.js new file mode 100644 index 00000000..4b800652 --- /dev/null +++ b/src/main/wiring/enchantingWiring.js @@ -0,0 +1,63 @@ +import { isPlayerAdjacentTo } from "./wiringUtils.js"; + +const INSTALLED = Symbol.for("jshack:main:enchantingWiring:installed"); +const EMPTY_INGREDIENTS = Object.freeze({ + emberRoot: 0, + moonleaf: 0, + thornPods: 0, + venomFronds: 0, + oil: 0, + water: 0, + gold: 0, +}); + +export function installEnchantingWiring({ world, playerEntity, dispatchRules, log }) { + if (!world || typeof playerEntity !== "function" || typeof dispatchRules !== "function") return; + if (world[INSTALLED]) return; + world[INSTALLED] = true; + + const writeLog = typeof log === "function" ? log : () => {}; + let activeBenchId = 0; + + world.on("enchanting:open", ({ actor, targetId, ingredients, recipes }) => { + const pe = playerEntity(world); + if (!pe || Number(actor || 0) !== pe.id) return; + const benchId = Number(targetId || 0) | 0; + if (!(benchId > 0)) return; + activeBenchId = benchId; + try { window.dispatchEvent(new CustomEvent("ui:openEnchantingBench", { detail: { benchId } })); } catch (e) { console.debug("[enchantingWiring] dispatch ui:openEnchantingBench:", e); } + try { + window.dispatchEvent(new CustomEvent("ui:enchantingBenchData", { + detail: { + benchId, + ingredients: ingredients && typeof ingredients === "object" ? ingredients : { ...EMPTY_INGREDIENTS }, + recipes: Array.isArray(recipes) ? recipes : [], + }, + })); + } catch (e) { console.debug("[enchantingWiring] dispatch ui:enchantingBenchData:", e); } + }); + + world.on("moved", ({ id }) => { + const pe = playerEntity(world); + if (!pe || Number(id || 0) !== pe.id) return; + if (!(activeBenchId > 0)) return; + if (isPlayerAdjacentTo(world, activeBenchId)) return; + activeBenchId = 0; + try { window.dispatchEvent(new CustomEvent("ui:closeEnchantingBench")); } catch (e) { console.debug("[enchantingWiring] dispatch ui:closeEnchantingBench:", e); } + }); + + addEventListener("ui:requestCraftEnchant", (ev) => { + /** @type {CustomEvent} */ // @ts-ignore + const e = ev; + const benchId = Number(e?.detail?.benchId || activeBenchId || 0) | 0; + const recipe = String(e?.detail?.recipe || "").trim().toLowerCase(); + if (!(benchId > 0) || !recipe) return; + if (!isPlayerAdjacentTo(world, benchId)) { + writeLog("You need to stand next to the enchanting bench."); + activeBenchId = 0; + try { window.dispatchEvent(new CustomEvent("ui:closeEnchantingBench")); } catch (err) { console.debug("[enchantingWiring] dispatch ui:closeEnchantingBench:", err); } + return; + } + dispatchRules({ type: "rules.craftEnchant", payload: { benchId, recipe } }); + }); +} diff --git a/src/rules/archetypes/Overworld.js b/src/rules/archetypes/Overworld.js index 37fa5378..b5776433 100644 --- a/src/rules/archetypes/Overworld.js +++ b/src/rules/archetypes/Overworld.js @@ -203,6 +203,15 @@ export const AlchemyBench = defineArchetype( [Interactable, { action: "brewAlchemy", params: null }], ); +export const EnchantingBench = defineArchetype( + "EnchantingBench", + [Position, (p) => ({ x: p.x, y: p.y })], + [NamedIdentity, { name: "Enchanting Bench", identity: "enchanting_bench" }], + [Material, { kind: "wood" }], + [Collider, { solid: true, blocksSight: false }], + [Interactable, { action: "craftEnchants", params: null }], +); + export const Anvil = defineArchetype( "Anvil", [Position, (p) => ({ x: p.x, y: p.y })], diff --git a/src/rules/content/enchanting/benchGame.js b/src/rules/content/enchanting/benchGame.js new file mode 100644 index 00000000..defcb140 --- /dev/null +++ b/src/rules/content/enchanting/benchGame.js @@ -0,0 +1,215 @@ +import { Inventory } from "../../components/Inventory.js"; +import { Position } from "../../components/Position.js"; +import { createItemById } from "../../utils/itemFactory.js"; +import { addToInventory, consumeFromStack, getStackCount } from "../../utils/inventoryFacade.js"; + +export const ENCHANTING_INGREDIENTS = Object.freeze({ + emberRoot: Object.freeze({ identity: "reagent_ember_root", label: "Ember Root" }), + moonleaf: Object.freeze({ identity: "reagent_moonleaf", label: "Moonleaf" }), + thornPods: Object.freeze({ identity: "reagent_thorn_pod", label: "Thorn Pods" }), + venomFronds: Object.freeze({ identity: "reagent_venom_frond", label: "Venom Fronds" }), + oil: Object.freeze({ identity: "potion_oil", label: "Flask of Oil" }), + water: Object.freeze({ identity: "potion_water", label: "Water Flask" }), + gold: Object.freeze({ identity: "gold", label: "Gold" }), +}); + +export const ENCHANTING_RECIPES = Object.freeze([ + Object.freeze({ + key: "venomous_script", + label: "Venomous Script", + outputIdentity: "scroll_enchant_poison", + outputName: "Scroll of Venom Binding", + enchantType: "poison", + affixId: "venomous1", + metadata: Object.freeze({ tier: 1, rarity: "magic" }), + requirements: Object.freeze({ venomFronds: 2, thornPods: 1, oil: 1, gold: 55 }), + effectSummary: "On hit, your gear can lace enemies with poison.", + flavor: "Fronds, resin, and oil are worked into a bitter green script.", + }), + Object.freeze({ + key: "firestorm_script", + label: "Firestorm Script", + outputIdentity: "scroll_enchant_fire", + outputName: "Scroll of Firestorm Binding", + enchantType: "fire", + affixId: "firestorm1", + metadata: Object.freeze({ tier: 1, rarity: "magic" }), + requirements: Object.freeze({ emberRoot: 2, thornPods: 1, oil: 1, gold: 60 }), + effectSummary: "On hit, your gear can kindle burning fire damage.", + flavor: "The scroll drinks heat from ember root and flashes with sparks.", + }), + Object.freeze({ + key: "frostbite_script", + label: "Frostbite Script", + outputIdentity: "scroll_enchant_frost", + outputName: "Scroll of Frost Binding", + enchantType: "frost", + affixId: "frostbite1", + metadata: Object.freeze({ tier: 1, rarity: "magic" }), + requirements: Object.freeze({ moonleaf: 2, water: 1, thornPods: 1, gold: 60 }), + effectSummary: "On hit, your gear can chill enemies with frost.", + flavor: "Cold silver leaf and clean water dry into a pale blue sigil.", + }), +]); + +function findRecipe(recipeKey) { + const key = String(recipeKey || "").trim().toLowerCase(); + if (!key) return null; + for (const recipe of ENCHANTING_RECIPES) { + if (recipe.key === key) return recipe; + } + return null; +} + +function countEnchantingIngredients(world, actor) { + return { + emberRoot: Math.max(0, Number(getStackCount(world, actor, ENCHANTING_INGREDIENTS.emberRoot.identity) || 0) | 0), + moonleaf: Math.max(0, Number(getStackCount(world, actor, ENCHANTING_INGREDIENTS.moonleaf.identity) || 0) | 0), + thornPods: Math.max(0, Number(getStackCount(world, actor, ENCHANTING_INGREDIENTS.thornPods.identity) || 0) | 0), + venomFronds: Math.max(0, Number(getStackCount(world, actor, ENCHANTING_INGREDIENTS.venomFronds.identity) || 0) | 0), + oil: Math.max(0, Number(getStackCount(world, actor, ENCHANTING_INGREDIENTS.oil.identity) || 0) | 0), + water: Math.max(0, Number(getStackCount(world, actor, ENCHANTING_INGREDIENTS.water.identity) || 0) | 0), + gold: Math.max(0, Number(getStackCount(world, actor, ENCHANTING_INGREDIENTS.gold.identity) || 0) | 0), + }; +} + +function hasEnoughIngredients(counts, requirements) { + const req = (requirements && typeof requirements === "object") ? requirements : {}; + for (const [key, raw] of Object.entries(req)) { + const need = Math.max(0, Number(raw || 0) | 0); + if (need <= 0) continue; + const have = Math.max(0, Number(counts?.[key] || 0) | 0); + if (have < need) return false; + } + return true; +} + +function consumeByIdentity(world, actor, identity, amount) { + const remaining = Math.max(0, Number(amount || 0) | 0); + if (remaining <= 0) return true; + const result = consumeFromStack(world, actor, identity, remaining); + if (result.consumed < remaining) return false; + for (const entityId of result.entities) { + try { world.destroy(entityId); } catch {} + } + return true; +} + +function consumeRequirements(world, actor, requirements) { + const req = (requirements && typeof requirements === "object") ? requirements : {}; + for (const [key, raw] of Object.entries(req)) { + const ingredient = ENCHANTING_INGREDIENTS[key]; + const need = Math.max(0, Number(raw || 0) | 0); + if (!ingredient || need <= 0) continue; + if (!consumeByIdentity(world, actor, ingredient.identity, need)) return false; + } + return true; +} + +function giveCraftedItem(world, actor, itemId) { + const createdId = createItemById(world, itemId); + if (!(createdId > 0)) return 0; + if (world.has(actor, Inventory) && addToInventory(world, actor, createdId)) return createdId; + const pos = world.get(actor, Position); + if (pos) world.add(createdId, Position, { x: pos.x, y: pos.y }); + return createdId; +} + +export function emitEnchantingBenchOpen(world, actor, targetId) { + const ingredients = countEnchantingIngredients(world, actor); + const recipes = ENCHANTING_RECIPES.map((recipe) => ({ + key: recipe.key, + label: recipe.label, + outputIdentity: recipe.outputIdentity, + outputName: recipe.outputName, + enchantType: recipe.enchantType, + affixId: recipe.affixId, + metadata: { ...(recipe.metadata || {}) }, + requirements: { ...(recipe.requirements || {}) }, + canCraft: hasEnoughIngredients(ingredients, recipe.requirements || {}), + effectSummary: recipe.effectSummary, + flavor: recipe.flavor, + })); + world.emit?.("enchanting:open", { actor, targetId, ingredients, recipes }); +} + +export function craftAtEnchantingBench(world, actor, targetId, recipeKey) { + if (!world.has(actor, Inventory)) { + world.emit?.("enchanting:result", { + actor, + targetId, + result: "no_inventory", + recipeKey: String(recipeKey || ""), + }); + return false; + } + + const recipe = findRecipe(recipeKey); + if (!recipe) { + emitEnchantingBenchOpen(world, actor, targetId); + world.emit?.("enchanting:result", { + actor, + targetId, + result: "unknown_recipe", + recipeKey: String(recipeKey || ""), + }); + return false; + } + + const ingredients = countEnchantingIngredients(world, actor); + if (!hasEnoughIngredients(ingredients, recipe.requirements || {})) { + const missing = {}; + for (const key of Object.keys(ENCHANTING_INGREDIENTS)) { + const required = Math.max(0, Number(recipe.requirements?.[key] || 0) | 0); + missing[key] = Math.max(0, required - Math.max(0, Number(ingredients[key] || 0) | 0)); + } + emitEnchantingBenchOpen(world, actor, targetId); + world.emit?.("enchanting:result", { + actor, + targetId, + result: "missing_requirements", + recipeKey: recipe.key, + missing, + have: ingredients, + need: { ...(recipe.requirements || {}) }, + }); + return false; + } + + if (!consumeRequirements(world, actor, recipe.requirements || {})) { + world.emit?.("enchanting:result", { + actor, + targetId, + result: "consume_failed", + recipeKey: recipe.key, + }); + return false; + } + + const itemId = giveCraftedItem(world, actor, recipe.outputIdentity); + if (!(itemId > 0)) { + world.emit?.("enchanting:result", { + actor, + targetId, + result: "craft_failed", + recipeKey: recipe.key, + }); + return false; + } + + world.emit?.("enchanting:crafted", { + actor, + targetId, + itemId, + recipeKey: recipe.key, + recipeLabel: recipe.label, + outputIdentity: recipe.outputIdentity, + outputName: recipe.outputName, + enchantType: recipe.enchantType, + affixId: recipe.affixId, + metadata: { ...(recipe.metadata || {}) }, + requirements: { ...(recipe.requirements || {}) }, + }); + emitEnchantingBenchOpen(world, actor, targetId); + return true; +} diff --git a/src/rules/content/interaction/interactPayloads.js b/src/rules/content/interaction/interactPayloads.js index 1f0dad76..3f72bf8b 100644 --- a/src/rules/content/interaction/interactPayloads.js +++ b/src/rules/content/interaction/interactPayloads.js @@ -72,6 +72,10 @@ import { brewAtAlchemyBench, emitAlchemyBenchOpen, } from "../alchemy/benchGame.js"; +import { + craftAtEnchantingBench, + emitEnchantingBenchOpen, +} from "../enchanting/benchGame.js"; import { cookAtFire, emitCookingFireOpen } from "../cooking/cookingGame.js"; import { emitAnvilOpen, forgeAtAnvil } from "../smithing/anvilGame.js"; import { createItemById } from "../../utils/itemFactory.js"; @@ -884,6 +888,19 @@ export const INTERACT_PAYLOADS = { }, }, + craftEnchants: { + onInteract(ctx) { + const { world, actor, targetId, intent } = ctx; + const interactionMode = String(intent?.mode || "").toLowerCase(); + const requestedRecipe = String(intent?.recipe || "").toLowerCase(); + if (interactionMode !== "enchant" || !requestedRecipe) { + emitEnchantingBenchOpen(world, actor, targetId); + return; + } + craftAtEnchantingBench(world, actor, targetId, requestedRecipe); + }, + }, + // ── Cooking ─────────────────────────────────────────────────────────────── cookFood: { diff --git a/src/rules/data/buildings/apothecary.js b/src/rules/data/buildings/apothecary.js index b2ddd80d..909894f5 100644 --- a/src/rules/data/buildings/apothecary.js +++ b/src/rules/data/buildings/apothecary.js @@ -70,6 +70,7 @@ export default { ], "spawns": [ { "dx": -3, "dy": -3, "kind": "alchemy_bench" }, + { "dx": 2, "dy": -3, "kind": "enchanting_bench" }, { "dx": -3, "dy": -1, "kind": "herb_chest" }, { "dx": -1, "dy": -4, "kind": "potion_shelf" }, { "dx": 1, "dy": -4, "kind": "potion_shelf" }, diff --git a/src/rules/data/itemCatalogMagic.js b/src/rules/data/itemCatalogMagic.js index b76439ee..1b76095c 100644 --- a/src/rules/data/itemCatalogMagic.js +++ b/src/rules/data/itemCatalogMagic.js @@ -48,6 +48,73 @@ function cleanupPriorExprEntity(ctx, targetId, effectKey) { } } +function canEnchantScrollTarget(state) { + const targetInfo = state?.targetInfo; + if (!targetInfo || String(targetInfo.type || "") !== "equip") return false; + return String(targetInfo.slot || "").toLowerCase() !== "ammo"; +} + +function createEnchantScrollUseHint(message) { + const text = String(message || "Choose a piece of gear to enchant."); + return () => ({ + consumed: false, + cancelled: true, + code: "USE_ENCHANT_SCROLL_TARGET", + message: text, + consumesTurn: false, + }); +} + +function createGearEnchantDipHook({ affixId, enchantType, enchantLabel, detail }) { + const resolvedAffixId = String(affixId || "").trim(); + const resolvedType = String(enchantType || "").trim().toLowerCase(); + const resolvedLabel = String(enchantLabel || resolvedType || "enchant"); + const resolvedDetail = String(detail || ""); + return (ctx, state) => { + const targetId = Number(state?.targetId || ctx.target || 0) | 0; + if (!(targetId > 0)) { + ctx.cancel({ + code: "ENCHANT_INVALID_TARGET", + message: "That scroll needs a piece of gear to bind to.", + consumesTurn: false, + }); + return { applied: false, consumedTool: false, resultType: "nothing" }; + } + const info = ctx.query.itemInfo(targetId); + if (!info || String(info.type || "") !== "equip") { + ctx.cancel({ + code: "ENCHANT_INVALID_TARGET", + message: "Only gear can hold that enchantment.", + consumesTurn: false, + }); + return { applied: false, consumedTool: false, resultType: "nothing" }; + } + const currentAffixes = Array.isArray(info.affixes) ? info.affixes.slice() : []; + const targetName = resolveApplyTargetName(ctx, state, "gear"); + if (currentAffixes.includes(resolvedAffixId)) { + ctx.cancel({ + code: "ENCHANT_ALREADY_PRESENT", + message: `${targetName} already bears ${resolvedLabel}.`, + consumesTurn: false, + }); + return { applied: false, consumedTool: false, resultType: "nothing" }; + } + ctx.helpers.patchItemInfo(targetId, { affixes: [...currentAffixes, resolvedAffixId] }); + ctx.io.emit("item:applied", { + actor: state.actor, + toolId: state.toolId, + targetId, + result: { + type: "gear_enchant", + enchantType: resolvedType, + affixId: resolvedAffixId, + message: `You bind ${resolvedLabel} into ${targetName}.${resolvedDetail ? ` ${resolvedDetail}` : ""}`, + }, + }); + return { applied: true, consumedTool: true, resultType: `${resolvedType}_gear_enchant` }; + }; +} + export const MAGIC_ITEMS = { // Magic / Usable stone_touchstone: { @@ -2355,6 +2422,75 @@ export const MAGIC_ITEMS = { value: 8, description: "A hot, peppery root that keeps its heat long after harvest.", }, + scroll_enchant_poison: { + id: "scroll_enchant_poison", + catalogKind: "magic", + name: "Scroll of Venom Binding", + type: "scroll", + slot: "bag", + material: "paper", + rarity: 2, + rarityName: "magic", + value: 120, + weight: 0.1, + description: "Apply to a piece of gear to bind a persistent venomous enchantment.", + hooks: { + can_dip_target: canEnchantScrollTarget, + on_dip: createGearEnchantDipHook({ + affixId: "venomous1", + enchantType: "poison", + enchantLabel: "Venomous", + detail: "Strikes from the enchanted gear can poison your enemies.", + }), + on_use: createEnchantScrollUseHint("Choose a piece of gear to bind the venom script into."), + }, + }, + scroll_enchant_fire: { + id: "scroll_enchant_fire", + catalogKind: "magic", + name: "Scroll of Firestorm Binding", + type: "scroll", + slot: "bag", + material: "paper", + rarity: 2, + rarityName: "magic", + value: 125, + weight: 0.1, + description: "Apply to a piece of gear to bind a persistent firestorm enchantment.", + hooks: { + can_dip_target: canEnchantScrollTarget, + on_dip: createGearEnchantDipHook({ + affixId: "firestorm1", + enchantType: "fire", + enchantLabel: "Firestorm", + detail: "Strikes from the enchanted gear can ignite lingering fire.", + }), + on_use: createEnchantScrollUseHint("Choose a piece of gear to bind the fire script into."), + }, + }, + scroll_enchant_frost: { + id: "scroll_enchant_frost", + catalogKind: "magic", + name: "Scroll of Frost Binding", + type: "scroll", + slot: "bag", + material: "paper", + rarity: 2, + rarityName: "magic", + value: 125, + weight: 0.1, + description: "Apply to a piece of gear to bind a persistent frostbite enchantment.", + hooks: { + can_dip_target: canEnchantScrollTarget, + on_dip: createGearEnchantDipHook({ + affixId: "frostbite1", + enchantType: "frost", + enchantLabel: "Frostbite", + detail: "Strikes from the enchanted gear can chill foes with frost.", + }), + on_use: createEnchantScrollUseHint("Choose a piece of gear to bind the frost script into."), + }, + }, // ── Scroll of Identify ───────────────────────────────────────────── scroll_identify: { id: "scroll_identify", diff --git a/src/rules/environment/dungeon/populate.js b/src/rules/environment/dungeon/populate.js index fe857a6e..b265dcba 100644 --- a/src/rules/environment/dungeon/populate.js +++ b/src/rules/environment/dungeon/populate.js @@ -44,6 +44,7 @@ import { OreVeinStone, TreeNode, AlchemyBench, + EnchantingBench, Anvil, Furnace, CookingFire, @@ -138,7 +139,7 @@ const SIMPLE_SPAWN_TABLE = { harvest_moonleaf: MoonleafCluster, harvest_ember_root: EmberRootPatch, harvest_iron_ore: OreVeinIron, harvest_coal_ore: OreVeinCoal, harvest_stone: OreVeinStone, tree_node: TreeNode, - alchemy_bench: AlchemyBench, anvil: Anvil, furnace: Furnace, + alchemy_bench: AlchemyBench, enchanting_bench: EnchantingBench, anvil: Anvil, furnace: Furnace, cooking_fire: CookingFire, crop_wheat: CropWheat, crop_carrot: CropCarrot, crop_corn: CropCorn, well: Well, scarecrow: Scarecrow, diff --git a/tests/enchantingBench.test.mjs b/tests/enchantingBench.test.mjs new file mode 100644 index 00000000..a0e07beb --- /dev/null +++ b/tests/enchantingBench.test.mjs @@ -0,0 +1,99 @@ +import { assert, assertEquals } from "jsr:@std/assert"; +import { createFrom, World } from "../src/lib/ecs-js/index.js"; +import { EnchantingBench } from "../src/rules/archetypes/Overworld.js"; +import { GoldStack } from "../src/rules/archetypes/Items.js"; +import { Inventory } from "../src/rules/components/Inventory.js"; +import { ItemInfo } from "../src/rules/components/ItemInfo.js"; +import { InteractIntent } from "../src/rules/components/Intents/InteractIntent.js"; +import { ApplyIntent } from "../src/rules/components/Intents/ApplyIntent.js"; +import { interactionSystem } from "../src/rules/systems/interactionSystem.js"; +import { applySystem } from "../src/rules/systems/applySystem.js"; +import { createItemById } from "../src/rules/utils/itemFactory.js"; +import { addToInventory, getStackCount, inventoryItems } from "../src/rules/utils/inventoryFacade.js"; +import "../src/rules/data/affixes.js"; + +function makeActor(world) { + const actor = world.create(); + world.add(actor, Inventory, { items: [], capacity: 20, weightLimit: null }); + return actor; +} + +function addStackedGold(world, actor, amount) { + const gold = createFrom(world, GoldStack, {}); + world.get(gold, ItemInfo).count = Math.max(1, Number(amount || 1) | 0); + addToInventory(world, actor, gold); + return gold; +} + +function addCatalogItem(world, actor, identity, count = 1) { + for (let i = 0; i < count; i++) { + const itemId = createItemById(world, identity); + assert(itemId > 0, `expected ${identity} to be creatable`); + addToInventory(world, actor, itemId); + } +} + +Deno.test("enchanting bench crafts a poison enchant scroll from reagents and gold", () => { + const world = new World({ seed: 1201 }); + const actor = makeActor(world); + addCatalogItem(world, actor, "reagent_venom_frond", 2); + addCatalogItem(world, actor, "reagent_thorn_pod", 1); + addCatalogItem(world, actor, "potion_oil", 1); + addStackedGold(world, actor, 55); + + const bench = createFrom(world, EnchantingBench, { x: 4, y: 4 }); + const crafted = []; + world.on("enchanting:crafted", (ev) => crafted.push(ev)); + + world.add(actor, InteractIntent, { + targetId: bench, + mode: "enchant", + recipe: "venomous_script", + }); + interactionSystem(world); + + assertEquals(crafted.length, 1); + assertEquals(crafted[0]?.outputIdentity, "scroll_enchant_poison"); + assertEquals(getStackCount(world, actor, "gold"), 0); + assertEquals(getStackCount(world, actor, "potion_oil"), 0); + assertEquals(getStackCount(world, actor, "reagent_venom_frond"), 0); + assert( + inventoryItems(world, actor).some((id) => world.get(id, ItemInfo)?.type === "scroll" && crafted[0]?.itemId === id), + "actor should receive the crafted enchant scroll", + ); +}); + +Deno.test("enchant scroll applies a persistent affix and rejects duplicate applications", () => { + const world = new World({ seed: 1202 }); + const actor = makeActor(world); + const weapon = createItemById(world, "dagger_quick"); + const firstScroll = createItemById(world, "scroll_enchant_fire"); + assert(weapon > 0 && firstScroll > 0, "required test items should be creatable"); + addToInventory(world, actor, weapon); + addToInventory(world, actor, firstScroll); + + const results = []; + const cancelled = []; + world.on("interaction:result", (ev) => results.push(ev)); + world.on("item:apply-cancelled", (ev) => cancelled.push(ev)); + + world.add(actor, ApplyIntent, { itemId: firstScroll, targetItemId: weapon }); + applySystem(world); + + assertEquals(results.length, 1); + assertEquals(results[0]?.ok, true); + assertEquals(results[0]?.metrics?.consumedTool, true); + assert(!world.isAlive(firstScroll), "successful enchant should consume the scroll"); + assert((world.get(weapon, ItemInfo)?.affixes || []).includes("firestorm1")); + + const secondScroll = createItemById(world, "scroll_enchant_fire"); + addToInventory(world, actor, secondScroll); + world.add(actor, ApplyIntent, { itemId: secondScroll, targetItemId: weapon }); + applySystem(world); + + assertEquals(results.length, 2); + assertEquals(results[1]?.ok, false); + assertEquals(cancelled.length, 1); + assertEquals(cancelled[0]?.code, "ENCHANT_ALREADY_PRESENT"); + assert(world.isAlive(secondScroll), "duplicate enchant should not consume the scroll"); +}); diff --git a/tests/inventoryDefaultAction.test.mjs b/tests/inventoryDefaultAction.test.mjs index f79ad523..61921ad6 100644 --- a/tests/inventoryDefaultAction.test.mjs +++ b/tests/inventoryDefaultAction.test.mjs @@ -36,6 +36,17 @@ Deno.test("inventory default action: usable non-slot items prioritize use over a assertEquals(getInventoryDefaultAction(potion), "use"); }); +Deno.test("inventory default action: apply-capable scrolls prioritize apply", () => { + const scroll = { + id: 250, + type: "scroll", + canApply: true, + applyTargetCount: 2, + }; + assertEquals(getInventoryDefaultAction(scroll), "apply"); + assertEquals(getQuickChipPrimaryAction(scroll), "use"); +}); + Deno.test("inventory default action: apply tools default to apply only with valid targets", () => { const tool = { id: 303, diff --git a/tests/rulesDispatchEnchanting.test.mjs b/tests/rulesDispatchEnchanting.test.mjs new file mode 100644 index 00000000..40f81e2f --- /dev/null +++ b/tests/rulesDispatchEnchanting.test.mjs @@ -0,0 +1,40 @@ +import { assertEquals } from "jsr:@std/assert"; +import { makeRulesDispatcher } from "../src/main/input/rulesDispatch.js"; +import { InteractIntent } from "../src/rules/components/Intents/InteractIntent.js"; + +Deno.test("rulesDispatch: craftEnchant queues InteractIntent in enchant mode", () => { + const addCalls = []; + const tickCalls = []; + const world = { + add: (...args) => addCalls.push(args), + tick: (...args) => tickCalls.push(args), + get: () => null, + }; + + const dispatch = makeRulesDispatcher(world, () => 77); + dispatch({ type: "rules.craftEnchant", payload: { benchId: 15, recipe: "Firestorm_Script" } }); + + assertEquals(addCalls.length, 1); + assertEquals(addCalls[0]?.[0], 77); + assertEquals(addCalls[0]?.[1], InteractIntent); + assertEquals(addCalls[0]?.[2], { targetId: 15, mode: "enchant", recipe: "firestorm_script" }); + assertEquals(tickCalls, [[1]]); +}); + +Deno.test("rulesDispatch: craftEnchant ignores invalid payloads", () => { + const addCalls = []; + const tickCalls = []; + const world = { + add: (...args) => addCalls.push(args), + tick: (...args) => tickCalls.push(args), + get: () => null, + }; + + const dispatch = makeRulesDispatcher(world, () => 9); + dispatch({ type: "rules.craftEnchant", payload: { benchId: 0, recipe: "firestorm_script" } }); + dispatch({ type: "rules.craftEnchant", payload: { benchId: -2, recipe: "firestorm_script" } }); + dispatch({ type: "rules.craftEnchant", payload: { benchId: 4, recipe: "" } }); + + assertEquals(addCalls.length, 0); + assertEquals(tickCalls.length, 0); +}); From 75e58c687f41057ef1b953363b7962acaea78c43 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:33:47 +0000 Subject: [PATCH 3/7] refine enchanting bench polish and validation fixes Agent-Logs-Url: https://github.com/PJensen/JSHack/sessions/53e9ee15-c468-4fcc-b0bf-a96a2a2354f1 Co-authored-by: PJensen <54164+PJensen@users.noreply.github.com> --- src/display/ui/inventoryOverlay.js | 7 ++----- src/display/ui/inventoryUtils.js | 12 +++++++++++- src/rules/content/enchanting/benchGame.js | 18 +++++++++++------- src/rules/data/itemCatalogMagic.js | 2 +- tests/rulesDispatchEnchanting.test.mjs | 2 +- 5 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/display/ui/inventoryOverlay.js b/src/display/ui/inventoryOverlay.js index 035ae856..d1b4d4d5 100644 --- a/src/display/ui/inventoryOverlay.js +++ b/src/display/ui/inventoryOverlay.js @@ -4,7 +4,7 @@ import { hide, hideItemTooltip, rarityStyle, renderItemDetails, installDetachableKeyHandler, pulseRow, } from './overlayUtils.js'; -import { getInventoryDefaultAction, isInventoryItemEquippable, isInventoryItemUsable } from './inventoryUtils.js'; +import { getInventoryDefaultAction, isInventoryItemEquippable, isInventoryItemUsable, shouldInventoryItemPreferApply } from './inventoryUtils.js'; /** @param {HTMLDivElement & {_inner?:HTMLDivElement}} panel @param {Array} items @param {any} [ground] @param {string} [slotFilter] */ export function renderInventory(panel, items, ground, slotFilter = '', scrollOfIdentifyId = 0, encumbrance = null, pinnedKeys = []) { @@ -431,10 +431,7 @@ export function renderInventory(panel, items, ground, slotFilter = '', scrollOfI return; } if (actionKey === 'use') { - const redirectsToApply = !!it?.canApply - && Number(it?.applyTargetCount || 0) > 0 - && (it?.type === 'scroll' || it?.type === 'tool' || it?.type === 'utility'); - if (redirectsToApply) { + if (shouldInventoryItemPreferApply(it)) { triggerApplyForTool(it); return; } diff --git a/src/display/ui/inventoryUtils.js b/src/display/ui/inventoryUtils.js index ff12d521..dc5adb7d 100644 --- a/src/display/ui/inventoryUtils.js +++ b/src/display/ui/inventoryUtils.js @@ -26,6 +26,16 @@ export function isInventoryItemUsable(it) { || it.type === 'tool'; } +/** + * @param {any} it + * @returns {boolean} + */ +export function shouldInventoryItemPreferApply(it) { + if (!it) return false; + if (!(it.canApply && Number(it.applyTargetCount || 0) > 0)) return false; + return it.type === 'scroll' || it.type === 'tool' || it.type === 'utility'; +} + /** * @param {any} it * @returns {"none"|"apply"|"equip"|"use"|"set-spell"} @@ -34,7 +44,7 @@ export function getInventoryDefaultAction(it) { if (!it) return 'none'; const canApply = !!it.canApply && Number(it.applyTargetCount || 0) > 0; if (isInventoryItemEquippable(it)) return 'equip'; - if (canApply && (it.type === 'scroll' || it.type === 'tool' || it.type === 'utility')) return 'apply'; + if (shouldInventoryItemPreferApply(it)) return 'apply'; if (isInventoryItemUsable(it)) return 'use'; if (canApply) return 'apply'; if (it.type === 'spell') return 'set-spell'; diff --git a/src/rules/content/enchanting/benchGame.js b/src/rules/content/enchanting/benchGame.js index defcb140..e32b1d90 100644 --- a/src/rules/content/enchanting/benchGame.js +++ b/src/rules/content/enchanting/benchGame.js @@ -61,15 +61,19 @@ function findRecipe(recipeKey) { return null; } +function safeCountIngredient(world, actor, identity) { + return Math.max(0, Number(getStackCount(world, actor, identity) || 0) | 0); +} + function countEnchantingIngredients(world, actor) { return { - emberRoot: Math.max(0, Number(getStackCount(world, actor, ENCHANTING_INGREDIENTS.emberRoot.identity) || 0) | 0), - moonleaf: Math.max(0, Number(getStackCount(world, actor, ENCHANTING_INGREDIENTS.moonleaf.identity) || 0) | 0), - thornPods: Math.max(0, Number(getStackCount(world, actor, ENCHANTING_INGREDIENTS.thornPods.identity) || 0) | 0), - venomFronds: Math.max(0, Number(getStackCount(world, actor, ENCHANTING_INGREDIENTS.venomFronds.identity) || 0) | 0), - oil: Math.max(0, Number(getStackCount(world, actor, ENCHANTING_INGREDIENTS.oil.identity) || 0) | 0), - water: Math.max(0, Number(getStackCount(world, actor, ENCHANTING_INGREDIENTS.water.identity) || 0) | 0), - gold: Math.max(0, Number(getStackCount(world, actor, ENCHANTING_INGREDIENTS.gold.identity) || 0) | 0), + emberRoot: safeCountIngredient(world, actor, ENCHANTING_INGREDIENTS.emberRoot.identity), + moonleaf: safeCountIngredient(world, actor, ENCHANTING_INGREDIENTS.moonleaf.identity), + thornPods: safeCountIngredient(world, actor, ENCHANTING_INGREDIENTS.thornPods.identity), + venomFronds: safeCountIngredient(world, actor, ENCHANTING_INGREDIENTS.venomFronds.identity), + oil: safeCountIngredient(world, actor, ENCHANTING_INGREDIENTS.oil.identity), + water: safeCountIngredient(world, actor, ENCHANTING_INGREDIENTS.water.identity), + gold: safeCountIngredient(world, actor, ENCHANTING_INGREDIENTS.gold.identity), }; } diff --git a/src/rules/data/itemCatalogMagic.js b/src/rules/data/itemCatalogMagic.js index 1b76095c..3545c361 100644 --- a/src/rules/data/itemCatalogMagic.js +++ b/src/rules/data/itemCatalogMagic.js @@ -71,7 +71,7 @@ function createGearEnchantDipHook({ affixId, enchantType, enchantLabel, detail } const resolvedLabel = String(enchantLabel || resolvedType || "enchant"); const resolvedDetail = String(detail || ""); return (ctx, state) => { - const targetId = Number(state?.targetId || ctx.target || 0) | 0; + const targetId = Number(state?.targetId || 0) | 0; if (!(targetId > 0)) { ctx.cancel({ code: "ENCHANT_INVALID_TARGET", diff --git a/tests/rulesDispatchEnchanting.test.mjs b/tests/rulesDispatchEnchanting.test.mjs index 40f81e2f..dcb41d3c 100644 --- a/tests/rulesDispatchEnchanting.test.mjs +++ b/tests/rulesDispatchEnchanting.test.mjs @@ -12,7 +12,7 @@ Deno.test("rulesDispatch: craftEnchant queues InteractIntent in enchant mode", ( }; const dispatch = makeRulesDispatcher(world, () => 77); - dispatch({ type: "rules.craftEnchant", payload: { benchId: 15, recipe: "Firestorm_Script" } }); + dispatch({ type: "rules.craftEnchant", payload: { benchId: 15, recipe: "firestorm_script" } }); assertEquals(addCalls.length, 1); assertEquals(addCalls[0]?.[0], 77); From 390abcc278c344dd611bbd7fc254b0cd31844cd6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 18:35:45 +0000 Subject: [PATCH 4/7] refactor enchanting inventory action helpers Agent-Logs-Url: https://github.com/PJensen/JSHack/sessions/53e9ee15-c468-4fcc-b0bf-a96a2a2354f1 Co-authored-by: PJensen <54164+PJensen@users.noreply.github.com> --- src/display/ui/inventoryOverlay.js | 4 ++-- src/display/ui/inventoryUtils.js | 14 ++++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/display/ui/inventoryOverlay.js b/src/display/ui/inventoryOverlay.js index d1b4d4d5..729055bc 100644 --- a/src/display/ui/inventoryOverlay.js +++ b/src/display/ui/inventoryOverlay.js @@ -4,7 +4,7 @@ import { hide, hideItemTooltip, rarityStyle, renderItemDetails, installDetachableKeyHandler, pulseRow, } from './overlayUtils.js'; -import { getInventoryDefaultAction, isInventoryItemEquippable, isInventoryItemUsable, shouldInventoryItemPreferApply } from './inventoryUtils.js'; +import { getInventoryDefaultAction, isInventoryItemEquippable, isInventoryItemUsable } from './inventoryUtils.js'; /** @param {HTMLDivElement & {_inner?:HTMLDivElement}} panel @param {Array} items @param {any} [ground] @param {string} [slotFilter] */ export function renderInventory(panel, items, ground, slotFilter = '', scrollOfIdentifyId = 0, encumbrance = null, pinnedKeys = []) { @@ -431,7 +431,7 @@ export function renderInventory(panel, items, ground, slotFilter = '', scrollOfI return; } if (actionKey === 'use') { - if (shouldInventoryItemPreferApply(it)) { + if (getInventoryDefaultAction(it) === 'apply') { triggerApplyForTool(it); return; } diff --git a/src/display/ui/inventoryUtils.js b/src/display/ui/inventoryUtils.js index dc5adb7d..5f7b6bea 100644 --- a/src/display/ui/inventoryUtils.js +++ b/src/display/ui/inventoryUtils.js @@ -26,13 +26,20 @@ export function isInventoryItemUsable(it) { || it.type === 'tool'; } +/** + * @param {any} it + * @returns {boolean} + */ +export function canInventoryItemApply(it) { + return !!it && !!it.canApply && Number(it.applyTargetCount || 0) > 0; +} + /** * @param {any} it * @returns {boolean} */ export function shouldInventoryItemPreferApply(it) { - if (!it) return false; - if (!(it.canApply && Number(it.applyTargetCount || 0) > 0)) return false; + if (!canInventoryItemApply(it)) return false; return it.type === 'scroll' || it.type === 'tool' || it.type === 'utility'; } @@ -42,11 +49,10 @@ export function shouldInventoryItemPreferApply(it) { */ export function getInventoryDefaultAction(it) { if (!it) return 'none'; - const canApply = !!it.canApply && Number(it.applyTargetCount || 0) > 0; if (isInventoryItemEquippable(it)) return 'equip'; if (shouldInventoryItemPreferApply(it)) return 'apply'; if (isInventoryItemUsable(it)) return 'use'; - if (canApply) return 'apply'; + if (canInventoryItemApply(it)) return 'apply'; if (it.type === 'spell') return 'set-spell'; return 'none'; } From ad0ff1b8aecc0445e0993a438bb0c4d998f4aa16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 20:08:23 +0000 Subject: [PATCH 5/7] feat: expand enchanting with dedicated NPC and reagent economy Agent-Logs-Url: https://github.com/PJensen/JSHack/sessions/51233f69-3ac5-47f4-9725-38190922cd68 Co-authored-by: PJensen <54164+PJensen@users.noreply.github.com> --- src/display/ui/enchantingBenchOverlay.js | 14 +- src/display/ui/overlay.js | 8 +- .../ui/wiring/messages/economyMessages.js | 21 +- src/main/scheduler.js | 2 + src/main/wiring/enchantingWiring.js | 16 +- src/rules/components/TownfolkJob.js | 1 + src/rules/content/enchanting/benchGame.js | 131 ++++------- .../content/enchanting/enchantCatalog.js | 124 ++++++++++ .../content/interaction/interactPayloads.js | 53 +++++ src/rules/data/affixes.js | 6 +- src/rules/data/buildings/apothecary.js | 3 +- src/rules/data/itemCatalogMagic.js | 212 +++++++++++++----- src/rules/data/lootTables.js | 103 +++++++++ src/rules/data/monsters.js | 4 + src/rules/data/shopStock.js | 12 + src/rules/data/townfolk.js | 8 + src/rules/data/townfolkAmbientDialogue.js | 22 ++ src/rules/dialogues/townfolkDialogs.js | 64 +++++- src/rules/environment/dungeon/populate.js | 12 + .../environment/dungeon/townPlacement.js | 22 +- src/rules/systems/aiTownfolkSystem.js | 8 + tests/enchantingBench.test.mjs | 53 ++++- tests/enchantingContent.test.mjs | 23 ++ 23 files changed, 744 insertions(+), 178 deletions(-) create mode 100644 src/rules/content/enchanting/enchantCatalog.js create mode 100644 tests/enchantingContent.test.mjs diff --git a/src/display/ui/enchantingBenchOverlay.js b/src/display/ui/enchantingBenchOverlay.js index ee41e126..5be4787c 100644 --- a/src/display/ui/enchantingBenchOverlay.js +++ b/src/display/ui/enchantingBenchOverlay.js @@ -3,8 +3,18 @@ const INGREDIENT_LABELS = Object.freeze({ moonleaf: "Moonleaf", thornPods: "Thorn Pods", venomFronds: "Venom Fronds", + spiderLeg: "Spider Leg", + venomGland: "Venom Gland", + resin: "Binding Resin", + boneDust: "Bone Dust", + ectoplasm: "Ectoplasm", + runeFragment: "Rune Fragment", + frostCore: "Frost Core", + beastClaw: "Beast Claw", + cursedThread: "Cursed Thread", oil: "Flask of Oil", water: "Water Flask", + ashes: "Ashes", gold: "Gold", }); @@ -45,7 +55,7 @@ export function renderEnchantingBench(panel, state) { el.innerHTML = ""; const title = document.createElement("div"); - title.textContent = "✧ Enchanting Bench"; + title.textContent = String(state?.title || "✧ Enchanting Bench"); Object.assign(title.style, { fontWeight: "bold", marginBottom: "8px", @@ -55,7 +65,7 @@ export function renderEnchantingBench(panel, state) { el.appendChild(title); const subtitle = document.createElement("div"); - subtitle.textContent = "Bind reagents and gold into a scroll, then apply it to your gear."; + subtitle.textContent = String(state?.subtitle || "Bind reagents and gold into a scroll, then apply it to your gear."); Object.assign(subtitle.style, { opacity: "0.86", marginBottom: "10px", diff --git a/src/display/ui/overlay.js b/src/display/ui/overlay.js index 61bd4ab2..efa3941f 100644 --- a/src/display/ui/overlay.js +++ b/src/display/ui/overlay.js @@ -787,8 +787,10 @@ export function initOverlays() { // Enchanting bench overlay let _enchantingState = { benchId: 0, - ingredients: { emberRoot: 0, moonleaf: 0, thornPods: 0, venomFronds: 0, oil: 0, water: 0, gold: 0 }, + ingredients: { emberRoot: 0, moonleaf: 0, thornPods: 0, venomFronds: 0, spiderLeg: 0, venomGland: 0, resin: 0, boneDust: 0, ectoplasm: 0, runeFragment: 0, frostCore: 0, beastClaw: 0, cursedThread: 0, oil: 0, water: 0, ashes: 0, gold: 0 }, recipes: [], + title: "✧ Enchanting Bench", + subtitle: "Bind reagents and gold into a scroll, then apply it to your gear.", }; window.addEventListener('ui:openEnchantingBench', (ev) => { /** @type {CustomEvent} */ // @ts-ignore @@ -830,8 +832,10 @@ export function initOverlays() { benchId: Number(d.benchId || _enchantingState.benchId || 0) | 0, ingredients: d.ingredients && typeof d.ingredients === 'object' ? d.ingredients - : { emberRoot: 0, moonleaf: 0, thornPods: 0, venomFronds: 0, oil: 0, water: 0, gold: 0 }, + : { emberRoot: 0, moonleaf: 0, thornPods: 0, venomFronds: 0, spiderLeg: 0, venomGland: 0, resin: 0, boneDust: 0, ectoplasm: 0, runeFragment: 0, frostCore: 0, beastClaw: 0, cursedThread: 0, oil: 0, water: 0, ashes: 0, gold: 0 }, recipes: Array.isArray(d.recipes) ? d.recipes : [], + title: String(d.title || _enchantingState.title || "✧ Enchanting Bench"), + subtitle: String(d.subtitle || _enchantingState.subtitle || "Bind reagents and gold into a scroll, then apply it to your gear."), }; renderEnchantingBench(alchemy, _enchantingState); }); diff --git a/src/display/ui/wiring/messages/economyMessages.js b/src/display/ui/wiring/messages/economyMessages.js index f023ae1a..ef9f079c 100644 --- a/src/display/ui/wiring/messages/economyMessages.js +++ b/src/display/ui/wiring/messages/economyMessages.js @@ -13,8 +13,18 @@ export function installEconomyMessages(ctx) { moonleaf: 'moonleaf', thornPods: 'thorn pods', venomFronds: 'venom fronds', + spiderLeg: 'spider legs', + venomGland: 'venom glands', + resin: 'binding resin', + boneDust: 'bone dust', + ectoplasm: 'ectoplasm', + runeFragment: 'rune fragments', + frostCore: 'frost cores', + beastClaw: 'beast claws', + cursedThread: 'cursed thread', oil: 'oil', water: 'water', + ashes: 'ashes', gold: 'gold', }; const parts = []; @@ -124,6 +134,12 @@ export function installEconomyMessages(ctx) { log(`You find some ${label} seeds!`, 'system'); }); + world.on('harvest:bonus_drop', ({ actor, kind, identity }) => { + if (nameOfEntity(actor) !== 'You') return; + const label = String(identity || 'a reagent').replace(/^reagent_/, '').replace(/_/g, ' '); + log(`You salvage ${label} from the ${harvestNodeLabel(kind)}.`, 'system'); + }); + world.on('seed:planted', ({ actor, kind }) => { if (nameOfEntity(actor) !== 'You') return; const label = kind === 'wheat' ? 'wheat' : kind === 'carrot' ? 'carrot' : kind === 'corn' ? 'corn' : kind; @@ -152,9 +168,10 @@ export function installEconomyMessages(ctx) { }); // === Enchanting events === - world.on('enchanting:open', ({ actor, ingredients }) => { + world.on('enchanting:open', ({ actor, ingredients, title }) => { if (nameOfEntity(actor) !== 'You') return; - log(`You open the enchanting bench. (${formatEnchantingBag(ingredients, { includeZero: true }) || "no stock"})`, 'system'); + const label = String(title || 'Enchanting Bench').replace(/^✧\s*/, ''); + log(`You consult the ${label.toLowerCase()}. (${formatEnchantingBag(ingredients, { includeZero: true }) || "no stock"})`, 'system'); }); world.on('enchanting:crafted', ({ actor, recipeLabel, outputName }) => { diff --git a/src/main/scheduler.js b/src/main/scheduler.js index 33569c88..d987cbdd 100644 --- a/src/main/scheduler.js +++ b/src/main/scheduler.js @@ -100,6 +100,7 @@ import { installGemSocketListener } from "../rules/data/gemSocketAffixes.js"; import { installElectrocuteOnDamage } from "../rules/utils/electrocute.js"; import { installCentipedeBodyCascade } from "../rules/utils/centipedeMovement.js"; import { installPerceptionMemoryListeners, perceptionMemorySystem } from "../rules/systems/perceptionMemorySystem.js"; +import { installEnchantingOpenRequestListener } from "../rules/content/enchanting/benchGame.js"; /** * @param {World} world @@ -162,6 +163,7 @@ export function configureWorld(world) { // Centipede body segments cascade position when the head moves. installCentipedeBodyCascade(world); installPerceptionMemoryListeners(world); + installEnchantingOpenRequestListener(world); // Phase: ai (intent producers — added intents are visible to later phases // in the same tick because ecs-js add() is intratick-immediate) diff --git a/src/main/wiring/enchantingWiring.js b/src/main/wiring/enchantingWiring.js index 4b800652..12a4254e 100644 --- a/src/main/wiring/enchantingWiring.js +++ b/src/main/wiring/enchantingWiring.js @@ -6,8 +6,18 @@ const EMPTY_INGREDIENTS = Object.freeze({ moonleaf: 0, thornPods: 0, venomFronds: 0, + spiderLeg: 0, + venomGland: 0, + resin: 0, + boneDust: 0, + ectoplasm: 0, + runeFragment: 0, + frostCore: 0, + beastClaw: 0, + cursedThread: 0, oil: 0, water: 0, + ashes: 0, gold: 0, }); @@ -19,7 +29,7 @@ export function installEnchantingWiring({ world, playerEntity, dispatchRules, lo const writeLog = typeof log === "function" ? log : () => {}; let activeBenchId = 0; - world.on("enchanting:open", ({ actor, targetId, ingredients, recipes }) => { + world.on("enchanting:open", ({ actor, targetId, ingredients, recipes, title, subtitle }) => { const pe = playerEntity(world); if (!pe || Number(actor || 0) !== pe.id) return; const benchId = Number(targetId || 0) | 0; @@ -32,6 +42,8 @@ export function installEnchantingWiring({ world, playerEntity, dispatchRules, lo benchId, ingredients: ingredients && typeof ingredients === "object" ? ingredients : { ...EMPTY_INGREDIENTS }, recipes: Array.isArray(recipes) ? recipes : [], + title: String(title || ""), + subtitle: String(subtitle || ""), }, })); } catch (e) { console.debug("[enchantingWiring] dispatch ui:enchantingBenchData:", e); } @@ -53,7 +65,7 @@ export function installEnchantingWiring({ world, playerEntity, dispatchRules, lo const recipe = String(e?.detail?.recipe || "").trim().toLowerCase(); if (!(benchId > 0) || !recipe) return; if (!isPlayerAdjacentTo(world, benchId)) { - writeLog("You need to stand next to the enchanting bench."); + writeLog("You need to stand next to the enchanting station."); activeBenchId = 0; try { window.dispatchEvent(new CustomEvent("ui:closeEnchantingBench")); } catch (err) { console.debug("[enchantingWiring] dispatch ui:closeEnchantingBench:", err); } return; diff --git a/src/rules/components/TownfolkJob.js b/src/rules/components/TownfolkJob.js index cec3f377..4a471364 100644 --- a/src/rules/components/TownfolkJob.js +++ b/src/rules/components/TownfolkJob.js @@ -11,6 +11,7 @@ export const TOWNFOLK_ROLES = Object.freeze({ mason: "mason", herbalist: "herbalist", alchemist: "alchemist", + enchantress: "enchantress", fisher: "fisher", gem_vendor: "gem_vendor", book_vendor: "book_vendor", diff --git a/src/rules/content/enchanting/benchGame.js b/src/rules/content/enchanting/benchGame.js index e32b1d90..1a92dc75 100644 --- a/src/rules/content/enchanting/benchGame.js +++ b/src/rules/content/enchanting/benchGame.js @@ -2,81 +2,30 @@ import { Inventory } from "../../components/Inventory.js"; import { Position } from "../../components/Position.js"; import { createItemById } from "../../utils/itemFactory.js"; import { addToInventory, consumeFromStack, getStackCount } from "../../utils/inventoryFacade.js"; +import { ENCHANTING_INGREDIENTS, getEnchantScrollDef, listEnchantRecipeDefs } from "./enchantCatalog.js"; -export const ENCHANTING_INGREDIENTS = Object.freeze({ - emberRoot: Object.freeze({ identity: "reagent_ember_root", label: "Ember Root" }), - moonleaf: Object.freeze({ identity: "reagent_moonleaf", label: "Moonleaf" }), - thornPods: Object.freeze({ identity: "reagent_thorn_pod", label: "Thorn Pods" }), - venomFronds: Object.freeze({ identity: "reagent_venom_frond", label: "Venom Fronds" }), - oil: Object.freeze({ identity: "potion_oil", label: "Flask of Oil" }), - water: Object.freeze({ identity: "potion_water", label: "Water Flask" }), - gold: Object.freeze({ identity: "gold", label: "Gold" }), -}); - -export const ENCHANTING_RECIPES = Object.freeze([ - Object.freeze({ - key: "venomous_script", - label: "Venomous Script", - outputIdentity: "scroll_enchant_poison", - outputName: "Scroll of Venom Binding", - enchantType: "poison", - affixId: "venomous1", - metadata: Object.freeze({ tier: 1, rarity: "magic" }), - requirements: Object.freeze({ venomFronds: 2, thornPods: 1, oil: 1, gold: 55 }), - effectSummary: "On hit, your gear can lace enemies with poison.", - flavor: "Fronds, resin, and oil are worked into a bitter green script.", - }), - Object.freeze({ - key: "firestorm_script", - label: "Firestorm Script", - outputIdentity: "scroll_enchant_fire", - outputName: "Scroll of Firestorm Binding", - enchantType: "fire", - affixId: "firestorm1", - metadata: Object.freeze({ tier: 1, rarity: "magic" }), - requirements: Object.freeze({ emberRoot: 2, thornPods: 1, oil: 1, gold: 60 }), - effectSummary: "On hit, your gear can kindle burning fire damage.", - flavor: "The scroll drinks heat from ember root and flashes with sparks.", - }), - Object.freeze({ - key: "frostbite_script", - label: "Frostbite Script", - outputIdentity: "scroll_enchant_frost", - outputName: "Scroll of Frost Binding", - enchantType: "frost", - affixId: "frostbite1", - metadata: Object.freeze({ tier: 1, rarity: "magic" }), - requirements: Object.freeze({ moonleaf: 2, water: 1, thornPods: 1, gold: 60 }), - effectSummary: "On hit, your gear can chill enemies with frost.", - flavor: "Cold silver leaf and clean water dry into a pale blue sigil.", - }), -]); +const INSTALLED_KEY = Symbol.for("jshack:enchanting:openRequest:installed"); + +function safeCountIngredient(world, actor, identity) { + return Math.max(0, Number(getStackCount(world, actor, identity) || 0) | 0); +} + +export function countEnchantingIngredients(world, actor) { + return Object.fromEntries( + Object.entries(ENCHANTING_INGREDIENTS).map(([key, def]) => [key, safeCountIngredient(world, actor, def.identity)]), + ); +} function findRecipe(recipeKey) { const key = String(recipeKey || "").trim().toLowerCase(); if (!key) return null; - for (const recipe of ENCHANTING_RECIPES) { - if (recipe.key === key) return recipe; + const recipes = listEnchantRecipeDefs(); + for (let i = 0; i < recipes.length; i++) { + if (recipes[i].key === key) return recipes[i]; } return null; } -function safeCountIngredient(world, actor, identity) { - return Math.max(0, Number(getStackCount(world, actor, identity) || 0) | 0); -} - -function countEnchantingIngredients(world, actor) { - return { - emberRoot: safeCountIngredient(world, actor, ENCHANTING_INGREDIENTS.emberRoot.identity), - moonleaf: safeCountIngredient(world, actor, ENCHANTING_INGREDIENTS.moonleaf.identity), - thornPods: safeCountIngredient(world, actor, ENCHANTING_INGREDIENTS.thornPods.identity), - venomFronds: safeCountIngredient(world, actor, ENCHANTING_INGREDIENTS.venomFronds.identity), - oil: safeCountIngredient(world, actor, ENCHANTING_INGREDIENTS.oil.identity), - water: safeCountIngredient(world, actor, ENCHANTING_INGREDIENTS.water.identity), - gold: safeCountIngredient(world, actor, ENCHANTING_INGREDIENTS.gold.identity), - }; -} - function hasEnoughIngredients(counts, requirements) { const req = (requirements && typeof requirements === "object") ? requirements : {}; for (const [key, raw] of Object.entries(req)) { @@ -119,25 +68,24 @@ function giveCraftedItem(world, actor, itemId) { return createdId; } -export function emitEnchantingBenchOpen(world, actor, targetId) { +export function emitEnchantingBenchOpen(world, actor, targetId, options = {}) { const ingredients = countEnchantingIngredients(world, actor); - const recipes = ENCHANTING_RECIPES.map((recipe) => ({ - key: recipe.key, - label: recipe.label, - outputIdentity: recipe.outputIdentity, - outputName: recipe.outputName, - enchantType: recipe.enchantType, - affixId: recipe.affixId, - metadata: { ...(recipe.metadata || {}) }, - requirements: { ...(recipe.requirements || {}) }, + const recipes = listEnchantRecipeDefs().map((recipe) => ({ + ...recipe, + metadata: { tier: 1, rarity: "magic" }, canCraft: hasEnoughIngredients(ingredients, recipe.requirements || {}), - effectSummary: recipe.effectSummary, - flavor: recipe.flavor, })); - world.emit?.("enchanting:open", { actor, targetId, ingredients, recipes }); + world.emit?.("enchanting:open", { + actor, + targetId, + ingredients, + recipes, + title: String(options.title || "✧ Enchanting Bench"), + subtitle: String(options.subtitle || "Bind reagents and gold into a scroll, then apply it to your gear."), + }); } -export function craftAtEnchantingBench(world, actor, targetId, recipeKey) { +export function craftAtEnchantingBench(world, actor, targetId, recipeKey, options = {}) { if (!world.has(actor, Inventory)) { world.emit?.("enchanting:result", { actor, @@ -150,7 +98,7 @@ export function craftAtEnchantingBench(world, actor, targetId, recipeKey) { const recipe = findRecipe(recipeKey); if (!recipe) { - emitEnchantingBenchOpen(world, actor, targetId); + emitEnchantingBenchOpen(world, actor, targetId, options); world.emit?.("enchanting:result", { actor, targetId, @@ -167,7 +115,7 @@ export function craftAtEnchantingBench(world, actor, targetId, recipeKey) { const required = Math.max(0, Number(recipe.requirements?.[key] || 0) | 0); missing[key] = Math.max(0, required - Math.max(0, Number(ingredients[key] || 0) | 0)); } - emitEnchantingBenchOpen(world, actor, targetId); + emitEnchantingBenchOpen(world, actor, targetId, options); world.emit?.("enchanting:result", { actor, targetId, @@ -211,9 +159,24 @@ export function craftAtEnchantingBench(world, actor, targetId, recipeKey) { outputName: recipe.outputName, enchantType: recipe.enchantType, affixId: recipe.affixId, - metadata: { ...(recipe.metadata || {}) }, + metadata: { tier: 1, rarity: "magic" }, requirements: { ...(recipe.requirements || {}) }, }); - emitEnchantingBenchOpen(world, actor, targetId); + emitEnchantingBenchOpen(world, actor, targetId, options); return true; } + +export function installEnchantingOpenRequestListener(world) { + if (!world || world[INSTALLED_KEY]) return; + world[INSTALLED_KEY] = true; + world.on("enchanting:openRequest", ({ actorId, targetId, title, subtitle }) => { + const actor = Number(actorId || 0) | 0; + const target = Number(targetId || 0) | 0; + if (!(actor > 0) || !(target > 0)) return; + emitEnchantingBenchOpen(world, actor, target, { title, subtitle }); + }); +} + +export function getEnchantScrollRecipe(itemId) { + return getEnchantScrollDef(itemId); +} diff --git a/src/rules/content/enchanting/enchantCatalog.js b/src/rules/content/enchanting/enchantCatalog.js new file mode 100644 index 00000000..bb431489 --- /dev/null +++ b/src/rules/content/enchanting/enchantCatalog.js @@ -0,0 +1,124 @@ +export const ENCHANTING_INGREDIENTS = Object.freeze({ + emberRoot: Object.freeze({ identity: "reagent_ember_root", label: "Ember Root" }), + moonleaf: Object.freeze({ identity: "reagent_moonleaf", label: "Moonleaf" }), + thornPods: Object.freeze({ identity: "reagent_thorn_pod", label: "Thorn Pods" }), + venomFronds: Object.freeze({ identity: "reagent_venom_frond", label: "Venom Fronds" }), + spiderLeg: Object.freeze({ identity: "reagent_spider_leg", label: "Spider Leg" }), + venomGland: Object.freeze({ identity: "reagent_venom_gland", label: "Venom Gland" }), + resin: Object.freeze({ identity: "reagent_resin", label: "Resin" }), + boneDust: Object.freeze({ identity: "reagent_bone_dust", label: "Bone Dust" }), + ectoplasm: Object.freeze({ identity: "reagent_ectoplasm", label: "Ectoplasm" }), + runeFragment: Object.freeze({ identity: "reagent_rune_fragment", label: "Rune Fragment" }), + frostCore: Object.freeze({ identity: "reagent_frost_core", label: "Frost Core" }), + beastClaw: Object.freeze({ identity: "reagent_beast_claw", label: "Beast Claw" }), + cursedThread: Object.freeze({ identity: "reagent_cursed_thread", label: "Cursed Thread" }), + oil: Object.freeze({ identity: "potion_oil", label: "Flask of Oil" }), + water: Object.freeze({ identity: "potion_water", label: "Water Flask" }), + ashes: Object.freeze({ identity: "ashes", label: "Ashes" }), + gold: Object.freeze({ identity: "gold", label: "Gold" }), +}); + +export const ENCHANT_SCROLL_DEFS = Object.freeze([ + Object.freeze({ + itemId: "scroll_enchant_poison", + recipeKey: "venomous_script", + name: "Scroll of Venom Binding", + enchantType: "poison", + affixId: "venomous1", + description: "Apply to a weapon to bind a persistent venomous enchantment.", + effectSummary: "On hit, weapon strikes can poison enemies.", + detail: "Strikes from the enchanted gear can poison your enemies.", + stationTitle: "✧ Enchantress's Satchel", + flavor: "Spider chitin, gland venom, and lacquered resin are worked into a bitter green script.", + requirements: Object.freeze({ spiderLeg: 2, venomGland: 1, resin: 1, gold: 65 }), + }), + Object.freeze({ + itemId: "scroll_enchant_fire", + recipeKey: "firestorm_script", + name: "Scroll of Firestorm Binding", + enchantType: "fire", + affixId: "firestorm1", + description: "Apply to a weapon to bind a persistent firestorm enchantment.", + effectSummary: "On hit, weapon strikes can kindle lingering fire damage.", + detail: "Strikes from the enchanted gear can ignite lingering fire.", + stationTitle: "✧ Enchantress's Satchel", + flavor: "Ember root, old ashes, and a scored rune flare together across the vellum.", + requirements: Object.freeze({ emberRoot: 2, ashes: 1, oil: 1, runeFragment: 1, gold: 70 }), + }), + Object.freeze({ + itemId: "scroll_enchant_frost", + recipeKey: "frostbite_script", + name: "Scroll of Frost Binding", + enchantType: "frost", + affixId: "frostbite1", + description: "Apply to a weapon to bind a persistent frostbite enchantment.", + effectSummary: "On hit, weapon strikes can chill enemies with frost.", + detail: "Strikes from the enchanted gear can chill foes with frost.", + stationTitle: "✧ Enchantress's Satchel", + flavor: "Moonleaf, meltwater, and a cold crystal set a pale blue sigil into the page.", + requirements: Object.freeze({ moonleaf: 2, water: 1, frostCore: 1, runeFragment: 1, gold: 70 }), + }), + Object.freeze({ + itemId: "scroll_enchant_flame_ward", + recipeKey: "flame_ward_script", + name: "Scroll of Flame Ward Binding", + enchantType: "fire ward", + affixId: "fireWard1", + description: "Apply to armor, offhand gear, or an amulet to bind a persistent flame ward.", + effectSummary: "Adds enduring fire resistance to defensive gear.", + detail: "The binding settles into the gear as a steady ward against flame.", + stationTitle: "✧ Enchantress's Satchel", + flavor: "Ash, resin, and ember-powder are stitched into a warding lattice.", + requirements: Object.freeze({ ashes: 1, resin: 1, emberRoot: 1, boneDust: 1, gold: 80 }), + }), + Object.freeze({ + itemId: "scroll_enchant_venom_ward", + recipeKey: "venom_ward_script", + name: "Scroll of Venom Ward Binding", + enchantType: "venom ward", + affixId: "poisonWard1", + description: "Apply to armor, rings, or an amulet to bind a persistent venom ward.", + effectSummary: "Adds enduring poison resistance to defensive gear.", + detail: "The script stiffens into a bitter ward against poison.", + stationTitle: "✧ Enchantress's Satchel", + flavor: "Fern sap, a venom sac, and black thread braid together into a warding seal.", + requirements: Object.freeze({ venomFronds: 2, venomGland: 1, cursedThread: 1, gold: 80 }), + }), + Object.freeze({ + itemId: "scroll_enchant_fortified", + recipeKey: "fortified_script", + name: "Scroll of Fortified Binding", + enchantType: "fortified", + affixId: "kineticWard1", + description: "Apply to armor, offhand gear, or an amulet to bind a persistent fortified ward.", + effectSummary: "Adds enduring impact resistance to heavy gear.", + detail: "The page hardens the gear into a patient, stubborn bulwark.", + stationTitle: "✧ Enchantress's Satchel", + flavor: "Resin, claw keratin, and grave dust press the script into a stubborn shell.", + requirements: Object.freeze({ resin: 1, beastClaw: 1, boneDust: 1, gold: 75 }), + }), +]); + +const ENCHANT_SCROLL_DEF_MAP = new Map(ENCHANT_SCROLL_DEFS.map((def) => [def.itemId, def])); + +export function listEnchantScrollDefs() { + return ENCHANT_SCROLL_DEFS.slice(); +} + +export function getEnchantScrollDef(itemId) { + return ENCHANT_SCROLL_DEF_MAP.get(String(itemId || "").trim()) || null; +} + +export function listEnchantRecipeDefs() { + return ENCHANT_SCROLL_DEFS.map((def) => ({ + key: def.recipeKey, + label: def.name.replace(/^Scroll of /, "").replace(/ Binding$/, ""), + outputIdentity: def.itemId, + outputName: def.name, + enchantType: def.enchantType, + affixId: def.affixId, + effectSummary: def.effectSummary, + flavor: def.flavor, + requirements: { ...(def.requirements || {}) }, + })); +} diff --git a/src/rules/content/interaction/interactPayloads.js b/src/rules/content/interaction/interactPayloads.js index 3f72bf8b..c4ee1ed6 100644 --- a/src/rules/content/interaction/interactPayloads.js +++ b/src/rules/content/interaction/interactPayloads.js @@ -120,12 +120,17 @@ const CATALOG_ARCHETYPES = { const HARVEST_SEED_SALT = 0x48415256; const SEED_DROP_SALT = 0x5345ED01; +const HARVEST_BONUS_DROP_SALT = 0x48B0A5D1; const SEED_ITEM_IDS = Object.freeze({ wheat: "seed_wheat", carrot: "seed_carrot", corn: "seed_corn", }); +const HARVEST_BONUS_DROPS = Object.freeze({ + tree: Object.freeze({ itemId: "reagent_resin", chance: 0.65, count: 1 }), + thorn_bramble: Object.freeze({ itemId: "reagent_resin", chance: 0.55, count: 1 }), +}); const FOUNTAIN_MIN_CHARGES = 2; const FOUNTAIN_MAX_CHARGES = 4; const FOUNTAIN_COOLDOWN_MIN = 201; @@ -901,6 +906,34 @@ export const INTERACT_PAYLOADS = { }, }, + openEnchantressServices: { + onInteract(ctx) { + const { world, actor, targetId, intent, params } = ctx; + const interactionMode = String(intent?.mode || "").toLowerCase(); + const requestedRecipe = String(intent?.recipe || "").toLowerCase(); + if (interactionMode === "enchant" && requestedRecipe) { + craftAtEnchantingBench(world, actor, targetId, requestedRecipe, { + title: "✧ Enchantress", + subtitle: "Choose the binding you want and I'll scribe the scroll if you've brought the price.", + }); + return; + } + const dialogId = String(params?.dialogId || "").trim(); + if (dialogId) { + world.emit?.("dialog:openRequest", { + actorId: actor, + targetId, + dialogId, + }); + return; + } + emitEnchantingBenchOpen(world, actor, targetId, { + title: "✧ Enchantress", + subtitle: "Bring themed reagents, gold, and the gear you want changed forever.", + }); + }, + }, + // ── Cooking ─────────────────────────────────────────────────────────────── cookFood: { @@ -1879,6 +1912,26 @@ export const INTERACT_PAYLOADS = { } } } + + const bonusDrop = HARVEST_BONUS_DROPS[node.kind]; + if (bonusDrop?.itemId) { + const seed = (((world.seed >>> 0) ^ Math.imul((targetId | 0), HARVEST_BONUS_DROP_SALT) ^ Math.imul((world.step | 0), 0x9e3779b9)) >>> 0); + const rng = createRng(seed); + if (rng.next() < Number(bonusDrop.chance || 0)) { + const bonusItemId = createItemById(world, bonusDrop.itemId); + if (bonusItemId) { + addToInventory(world, actor, bonusItemId); + world.emit?.("harvest:bonus_drop", { + actor, + targetId, + kind: node.kind, + itemId: bonusItemId, + identity: bonusDrop.itemId, + count: Math.max(1, Number(bonusDrop.count || 1) | 0), + }); + } + } + } }, }, diff --git a/src/rules/data/affixes.js b/src/rules/data/affixes.js index 6abc5416..8fbfad62 100644 --- a/src/rules/data/affixes.js +++ b/src/rules/data/affixes.js @@ -761,9 +761,9 @@ export function unregisterAffixDefinition(id) { ["guard1", { name: "Guarded", slots: ["armor"], weight: 25, passiveRefs: [AFFIX_GUARD] }], ["life1", { name: "Healthy", slots: ["armor", "ring"], weight: 22, passiveRefs: [AFFIX_LIFE] }], ["attuned1", { name: "Attuned", slots: ["ring"], weight: 20, passiveRefs: [AFFIX_ATTUNED] }], - ["fireWard1", { name: "Flame Ward", slots: ["armor", "offhand"], weight: 18, passiveRefs: [AFFIX_FIRE_WARD] }], - ["poisonWard1", { name: "Venom Ward", slots: ["armor", "ring"], weight: 18, passiveRefs: [AFFIX_POISON_WARD] }], - ["kineticWard1", { name: "Fortified", slots: ["armor", "offhand"], weight: 15, passiveRefs: [AFFIX_KINETIC_WARD] }], + ["fireWard1", { name: "Flame Ward", slots: ["armor", "offhand", "neck"], weight: 18, passiveRefs: [AFFIX_FIRE_WARD] }], + ["poisonWard1", { name: "Venom Ward", slots: ["armor", "ring", "neck"], weight: 18, passiveRefs: [AFFIX_POISON_WARD] }], + ["kineticWard1", { name: "Fortified", slots: ["armor", "offhand", "neck"], weight: 15, passiveRefs: [AFFIX_KINETIC_WARD] }], ["caustic1", { name: "Caustic", slots: ["weapon"], weight: 16, elementTint: ELEMENT_TINT_ACID, triggerScripts: { onHit: [AFFIX_CAUSTIC] } }], ["capacitive1", { name: "Capacitive", slots: ["weapon"], weight: 15, elementTint: ELEMENT_TINT_ELECTRIC, triggerScripts: { onHit: [AFFIX_CAPACITIVE] } }], ["insulated1", { name: "Insulated", slots: ["armor", "offhand"], weight: 16, passiveRefs: [AFFIX_INSULATED] }], diff --git a/src/rules/data/buildings/apothecary.js b/src/rules/data/buildings/apothecary.js index 909894f5..420ddbc6 100644 --- a/src/rules/data/buildings/apothecary.js +++ b/src/rules/data/buildings/apothecary.js @@ -83,7 +83,8 @@ export default { ], "waypoints": [ { "dx": 0, "dy": 0, "name": "shop_door" }, - { "dx": -3, "dy": -3, "name": "vendor_work" } + { "dx": -3, "dy": -3, "name": "vendor_work" }, + { "dx": 1, "dy": -3, "name": "enchantress_work" } ], "rooms": [ { "name": "shop", "roomType": "shop", "dx": -5, "dy": -5, "w": 10, "h": 6 } diff --git a/src/rules/data/itemCatalogMagic.js b/src/rules/data/itemCatalogMagic.js index 3545c361..89fff7db 100644 --- a/src/rules/data/itemCatalogMagic.js +++ b/src/rules/data/itemCatalogMagic.js @@ -31,6 +31,8 @@ import { createStatusEvent } from "../../shared/events/statusEvent.js"; import { getPassiveBonuses } from "../utils/passiveBonuses.js"; import { attachDerivedExpression, exprAddConst } from "../utils/statProcAuthoring.js"; import { resolveItemCooldownRemaining } from "../utils/itemCooldowns.js"; +import { affixSupportsSlot } from "./affixes.js"; +import { ENCHANT_SCROLL_DEFS, getEnchantScrollDef } from "../content/enchanting/enchantCatalog.js"; /** * Destroy any existing DerivedExpression entity owned by an active effect @@ -51,7 +53,11 @@ function cleanupPriorExprEntity(ctx, targetId, effectKey) { function canEnchantScrollTarget(state) { const targetInfo = state?.targetInfo; if (!targetInfo || String(targetInfo.type || "") !== "equip") return false; - return String(targetInfo.slot || "").toLowerCase() !== "ammo"; + const slot = String(targetInfo.slot || "").toLowerCase(); + if (!slot || slot === "ammo") return false; + const scrollDef = getEnchantScrollDef(state?.toolIdentity || state?.toolId || ""); + if (!scrollDef?.affixId) return false; + return affixSupportsSlot(scrollDef.affixId, slot); } function createEnchantScrollUseHint(message) { @@ -89,8 +95,17 @@ function createGearEnchantDipHook({ affixId, enchantType, enchantLabel, detail } }); return { applied: false, consumedTool: false, resultType: "nothing" }; } - const currentAffixes = Array.isArray(info.affixes) ? info.affixes.slice() : []; const targetName = resolveApplyTargetName(ctx, state, "gear"); + const slot = String(info.slot || "").toLowerCase(); + if (!affixSupportsSlot(resolvedAffixId, slot)) { + ctx.cancel({ + code: "ENCHANT_INVALID_SLOT", + message: `${targetName} cannot hold ${resolvedLabel}.`, + consumesTurn: false, + }); + return { applied: false, consumedTool: false, resultType: "nothing" }; + } + const currentAffixes = Array.isArray(info.affixes) ? info.affixes.slice() : []; if (currentAffixes.includes(resolvedAffixId)) { ctx.cancel({ code: "ENCHANT_ALREADY_PRESENT", @@ -115,6 +130,32 @@ function createGearEnchantDipHook({ affixId, enchantType, enchantLabel, detail } }; } +const ENCHANT_SCROLL_ITEMS = Object.fromEntries( + ENCHANT_SCROLL_DEFS.map((def) => [def.itemId, { + id: def.itemId, + catalogKind: "magic", + name: def.name, + type: "scroll", + slot: "bag", + material: "paper", + rarity: 2, + rarityName: "magic", + value: 120, + weight: 0.1, + description: def.description, + hooks: { + can_dip_target: canEnchantScrollTarget, + on_dip: createGearEnchantDipHook({ + affixId: def.affixId, + enchantType: def.enchantType, + enchantLabel: def.name.replace(/^Scroll of /, "").replace(/ Binding$/, ""), + detail: def.detail, + }), + on_use: createEnchantScrollUseHint(`Choose a piece of gear for ${def.name.toLowerCase()}.`), + }, + }]), +); + export const MAGIC_ITEMS = { // Magic / Usable stone_touchstone: { @@ -2422,75 +2463,124 @@ export const MAGIC_ITEMS = { value: 8, description: "A hot, peppery root that keeps its heat long after harvest.", }, - scroll_enchant_poison: { - id: "scroll_enchant_poison", - catalogKind: "magic", - name: "Scroll of Venom Binding", - type: "scroll", + reagent_spider_leg: { + id: "reagent_spider_leg", + catalogKind: "material", + name: "Spider Leg", + type: "ingredient", slot: "bag", - material: "paper", + material: "organic", + rarity: 1, + rarityName: "common", + weight: 0.15, + value: 7, + description: "A hooked spider leg, dried stiff for poison work and binding sigils.", + }, + reagent_venom_gland: { + id: "reagent_venom_gland", + catalogKind: "material", + name: "Venom Gland", + type: "ingredient", + slot: "bag", + material: "organic", rarity: 2, - rarityName: "magic", - value: 120, - weight: 0.1, - description: "Apply to a piece of gear to bind a persistent venomous enchantment.", - hooks: { - can_dip_target: canEnchantScrollTarget, - on_dip: createGearEnchantDipHook({ - affixId: "venomous1", - enchantType: "poison", - enchantLabel: "Venomous", - detail: "Strikes from the enchanted gear can poison your enemies.", - }), - on_use: createEnchantScrollUseHint("Choose a piece of gear to bind the venom script into."), - }, + rarityName: "uncommon", + weight: 0.2, + value: 12, + description: "A sealed venom sac prized by poisoners and enchanters alike.", }, - scroll_enchant_fire: { - id: "scroll_enchant_fire", - catalogKind: "magic", - name: "Scroll of Firestorm Binding", - type: "scroll", + reagent_resin: { + id: "reagent_resin", + catalogKind: "material", + name: "Binding Resin", + type: "ingredient", slot: "bag", - material: "paper", + material: "resin", + rarity: 1, + rarityName: "common", + weight: 0.2, + value: 8, + description: "Sticky amber resin used to seal enchantments into gear.", + }, + reagent_bone_dust: { + id: "reagent_bone_dust", + catalogKind: "material", + name: "Bone Dust", + type: "ingredient", + slot: "bag", + material: "bone", + rarity: 1, + rarityName: "common", + weight: 0.15, + value: 8, + description: "Pale dust from shattered bone, useful for warding work.", + }, + reagent_ectoplasm: { + id: "reagent_ectoplasm", + catalogKind: "material", + name: "Ectoplasm", + type: "ingredient", + slot: "bag", + material: "organic", rarity: 2, - rarityName: "magic", - value: 125, - weight: 0.1, - description: "Apply to a piece of gear to bind a persistent firestorm enchantment.", - hooks: { - can_dip_target: canEnchantScrollTarget, - on_dip: createGearEnchantDipHook({ - affixId: "firestorm1", - enchantType: "fire", - enchantLabel: "Firestorm", - detail: "Strikes from the enchanted gear can ignite lingering fire.", - }), - on_use: createEnchantScrollUseHint("Choose a piece of gear to bind the fire script into."), - }, + rarityName: "uncommon", + weight: 0.15, + value: 13, + description: "Cold spectral residue that clings to glass and cloth.", }, - scroll_enchant_frost: { - id: "scroll_enchant_frost", - catalogKind: "magic", - name: "Scroll of Frost Binding", - type: "scroll", + reagent_rune_fragment: { + id: "reagent_rune_fragment", + catalogKind: "material", + name: "Rune Fragment", + type: "ingredient", slot: "bag", - material: "paper", + material: "stone", rarity: 2, - rarityName: "magic", - value: 125, + rarityName: "uncommon", weight: 0.1, - description: "Apply to a piece of gear to bind a persistent frostbite enchantment.", - hooks: { - can_dip_target: canEnchantScrollTarget, - on_dip: createGearEnchantDipHook({ - affixId: "frostbite1", - enchantType: "frost", - enchantLabel: "Frostbite", - detail: "Strikes from the enchanted gear can chill foes with frost.", - }), - on_use: createEnchantScrollUseHint("Choose a piece of gear to bind the frost script into."), - }, + value: 14, + description: "A splinter of worked sigil-stone, still holding a charge.", + }, + reagent_frost_core: { + id: "reagent_frost_core", + catalogKind: "material", + name: "Frost Core", + type: "ingredient", + slot: "bag", + material: "ice", + rarity: 2, + rarityName: "uncommon", + weight: 0.25, + value: 15, + description: "A crystal heart of trapped cold lifted from winter-touched foes.", + }, + reagent_beast_claw: { + id: "reagent_beast_claw", + catalogKind: "material", + name: "Beast Claw", + type: "ingredient", + slot: "bag", + material: "bone", + rarity: 1, + rarityName: "common", + weight: 0.2, + value: 9, + description: "A heavy claw with enough bite left in it to anchor tougher wards.", + }, + reagent_cursed_thread: { + id: "reagent_cursed_thread", + catalogKind: "material", + name: "Cursed Thread", + type: "ingredient", + slot: "bag", + material: "cloth", + rarity: 2, + rarityName: "uncommon", + weight: 0.05, + value: 16, + description: "Black thread knotted with a whisper of malice.", }, + ...ENCHANT_SCROLL_ITEMS, // ── Scroll of Identify ───────────────────────────────────────────── scroll_identify: { id: "scroll_identify", diff --git a/src/rules/data/lootTables.js b/src/rules/data/lootTables.js index 7065431f..d6cb9346 100644 --- a/src/rules/data/lootTables.js +++ b/src/rules/data/lootTables.js @@ -397,6 +397,58 @@ export const LOOT_TABLES = { ], }, + "sub:reagents_beast": { + rolls: { min: 1, max: 1 }, + entries: [ + { type: "item", weight: 22, itemId: "reagent_beast_claw" }, + { type: "item", weight: 10, itemId: "reagent_resin" }, + { type: "item", weight: 8, itemId: "reagent_venom_gland" }, + { type: "nothing", weight: 20 }, + ], + }, + + "sub:reagents_spider": { + rolls: { min: 1, max: 2 }, + entries: [ + { type: "item", weight: 30, itemId: "reagent_spider_leg" }, + { type: "item", weight: 18, itemId: "reagent_venom_gland" }, + { type: "item", weight: 10, itemId: "reagent_resin" }, + { type: "nothing", weight: 12 }, + ], + }, + + "sub:reagents_undead": { + rolls: { min: 1, max: 1 }, + entries: [ + { type: "item", weight: 24, itemId: "reagent_bone_dust" }, + { type: "item", weight: 16, itemId: "reagent_ectoplasm" }, + { type: "archetype", weight: 12, archetype: "Ashes" }, + { type: "nothing", weight: 20 }, + ], + }, + + "sub:reagents_occult": { + rolls: { min: 1, max: 1 }, + entries: [ + { type: "item", weight: 20, itemId: "reagent_rune_fragment" }, + { type: "item", weight: 16, itemId: "reagent_cursed_thread" }, + { type: "item", weight: 10, itemId: "reagent_ectoplasm" }, + { type: "item", weight: 10, itemId: "reagent_frost_core" }, + { type: "nothing", weight: 18 }, + ], + }, + + "sub:reagents_plant": { + rolls: { min: 1, max: 1 }, + entries: [ + { type: "item", weight: 16, itemId: "reagent_resin" }, + { type: "item", weight: 14, itemId: "reagent_moonleaf" }, + { type: "item", weight: 14, itemId: "reagent_ember_root" }, + { type: "item", weight: 12, itemId: "reagent_thorn_pod" }, + { type: "nothing", weight: 18 }, + ], + }, + // ── Monster tier defaults (fallback for untagged monsters) ──────── // Most monsters route through tag-based tables (drop:beast, drop:humanoid, // drop:undead, drop:caster). These tier tables serve aberrations, giants, @@ -504,6 +556,8 @@ export const LOOT_TABLES = { entries: [ { type: "nothing", weight: 50 }, { type: "gold", weight: 30, count: { base: 16, perDepth: 6 } }, + { type: "table", weight: 12, tableId: "sub:reagents_occult" }, + { type: "table", weight: 10, tableId: "sub:reagents_plant" }, { type: "table", weight: 10, tableId: "sub:potions" }, { type: "table", weight: 8, tableId: "sub:jewelry" }, { type: "table", weight: 5, tableId: "sub:scrolls" }, @@ -519,6 +573,7 @@ export const LOOT_TABLES = { entries: [ { type: "nothing", weight: 50 }, { type: "gold", weight: 28, count: { base: 5, perDepth: 3 } }, + { type: "table", weight: 18, tableId: "sub:reagents_beast" }, { type: "archetype", weight: 15, archetype: "Ration" }, { type: "archetype", weight: 10, archetype: "HealthPotion" }, { type: "table", weight: 8, tableId: "sub:equip_early_proc" }, @@ -532,6 +587,7 @@ export const LOOT_TABLES = { entries: [ { type: "nothing", weight: 45 }, { type: "gold", weight: 30, count: { base: 8, perDepth: 5 } }, + { type: "table", weight: 18, tableId: "sub:reagents_spider" }, { type: "item", weight: 9, itemId: "potion_resist_poison" }, { type: "item", weight: 6, itemId: "potion_anti_venom" }, { type: "table", weight: 8, tableId: "sub:potions" }, @@ -541,6 +597,17 @@ export const LOOT_TABLES = { ], }, + "drop:plant": { + rolls: { min: 1, max: 1 }, + entries: [ + { type: "nothing", weight: 42 }, + { type: "table", weight: 24, tableId: "sub:reagents_plant" }, + { type: "archetype", weight: 14, archetype: "WildHerbs" }, + { type: "archetype", weight: 10, archetype: "WildBerries" }, + { type: "table", weight: 6, tableId: "sub:potions" }, + ], + }, + "drop:humanoid": { rolls: { min: 1, max: 1 }, entries: [ @@ -569,6 +636,7 @@ export const LOOT_TABLES = { entries: [ { type: "nothing", weight: 40 }, { type: "gold", weight: 35, count: { base: 14, perDepth: 6 } }, + { type: "table", weight: 18, tableId: "sub:reagents_undead" }, { type: "item", weight: 6, itemId: "potion_holy_water" }, { type: "table", weight: 11, tableId: "sub:scrolls" }, { type: "table", weight: 8, tableId: "sub:potions" }, @@ -592,6 +660,7 @@ export const LOOT_TABLES = { entries: [ { type: "nothing", weight: 40 }, { type: "gold", weight: 25, count: { base: 16, perDepth: 6 } }, + { type: "table", weight: 16, tableId: "sub:reagents_occult" }, { type: "table", weight: 17, tableId: "sub:scrolls" }, { type: "table", weight: 14, tableId: "sub:spellbooks" }, { type: "table", weight: 10, tableId: "sub:wands" }, @@ -624,6 +693,19 @@ export const LOOT_TABLES = { ], }, + "drop:spider": { + rolls: { min: 1, max: 1 }, + entries: [ + { type: "nothing", weight: 40 }, + { type: "gold", weight: 14, count: { base: 4, perDepth: 2 } }, + { type: "table", weight: 26, tableId: "sub:reagents_spider" }, + { type: "item", weight: 6, itemId: "potion_anti_venom" }, + { type: "item", weight: 6, itemId: "potion_resist_poison" }, + { type: "table", weight: 8, tableId: "sub:equip_early_proc" }, + { type: "table", weight: 6, tableId: "sub:potions" }, + ], + }, + "drop:wight": { rolls: { min: 1, max: 1 }, entries: [ @@ -632,6 +714,7 @@ export const LOOT_TABLES = { { type: "item", weight: 9, itemId: "potion_holy_water" }, { type: "item", weight: 7, itemId: "scroll_remove_curse" }, { type: "item", weight: 5, itemId: "potion_anti_venom" }, + { type: "table", weight: 16, tableId: "sub:reagents_undead" }, { type: "table", weight: 8, tableId: "sub:potions" }, { type: "table", weight: 7, tableId: "sub:scrolls" }, { type: "table", weight: 6, tableId: "sub:equip_magic" }, @@ -640,6 +723,20 @@ export const LOOT_TABLES = { ], }, + "drop:witch": { + rolls: { min: 1, max: 1 }, + entries: [ + { type: "nothing", weight: 34 }, + { type: "gold", weight: 20, count: { base: 18, perDepth: 6 } }, + { type: "table", weight: 20, tableId: "sub:reagents_occult" }, + { type: "table", weight: 10, tableId: "sub:scrolls" }, + { type: "table", weight: 8, tableId: "sub:potions" }, + { type: "item", weight: 8, itemId: "scroll_remove_curse" }, + { type: "item", weight: 6, itemId: "scroll_enchant_venom_ward" }, + { type: "item", weight: 6, itemId: "scroll_enchant_frost" }, + ], + }, + "drop:pit_viper": { rolls: { min: 1, max: 1 }, entries: [ @@ -697,6 +794,8 @@ export const LOOT_TABLES = { entries: [ { type: "nothing", weight: 40 }, { type: "gold", weight: 30, count: { base: 35, perDepth: 7 } }, + { type: "item", weight: 14, itemId: "reagent_rune_fragment" }, + { type: "archetype", weight: 12, archetype: "Ashes" }, { type: "equip", weight: 14, pool: ["ember_knife", "smoldering_club", "ring_fire_resist"], affixChance: 0 }, { type: "equip", weight: 8, pool: ["shield_fireward"], affixChance: 0.15, affixCountMax: 1 }, { type: "table", weight: 9, tableId: "sub:potions" }, @@ -710,6 +809,8 @@ export const LOOT_TABLES = { entries: [ { type: "nothing", weight: 25 }, { type: "gold", weight: 30, count: { base: 85, perDepth: 15 } }, + { type: "item", weight: 18, itemId: "reagent_rune_fragment" }, + { type: "archetype", weight: 14, archetype: "Ashes" }, { type: "equip", weight: 18, pool: ["axe_heavy", "chain_armor", "amulet_vigor", "belt_girded", "gauntlets_iron", "greaves_steel", "helm_steel", "shield_iron", "ring_health", "ring_precision", "ring_arcana", "ring_fire_resist", "ring_poison_resist", "ring_cold_resist", "ring_shock_resist", "shield_fireward", "leadweave_mantle", "ring_endurance", "shield_spiked_pavise"], affixChance: 0.80, affixCountMax: 2 }, { type: "equip", weight: 1, pool: ["warhammer"], affixChance: 0, affixCountMax: 0 }, @@ -728,6 +829,8 @@ export const LOOT_TABLES = { rolls: { min: 1, max: 1 }, entries: [ { type: "nothing", weight: 35 }, + { type: "table", weight: 18, tableId: "sub:reagents_undead" }, + { type: "table", weight: 16, tableId: "sub:reagents_occult" }, { type: "table", weight: 20, tableId: "sub:spellbooks" }, { type: "gold", weight: 22, count: { base: 35, perDepth: 8 } }, { type: "table", weight: 10, tableId: "sub:scrolls" }, diff --git a/src/rules/data/monsters.js b/src/rules/data/monsters.js index 9a0ce0af..3563e7fb 100644 --- a/src/rules/data/monsters.js +++ b/src/rules/data/monsters.js @@ -432,6 +432,7 @@ export const MONSTERS = [ }, specials: ["Throws web (25%)", "Web spit", "Lunge (stagger)"], description: 'A skittish arachnid. It spins webs but lacks venom.', + lootTable: 'drop:spider', }, { @@ -1057,6 +1058,7 @@ export const MONSTERS = [ }, specials: ["Phase teleport (50%)", "Poison 30%", "Phase out 20%"], description: 'A shimmering spider that blinks in and out of existence, striking from impossible angles.', + lootTable: 'drop:spider', }, { id: 'wight', @@ -1133,6 +1135,7 @@ export const MONSTERS = [ }, specials: ["Web spit", "Poison 15%"], description: 'A dog-sized arachnid with venomous fangs.', + lootTable: 'drop:spider', }, { @@ -1836,6 +1839,7 @@ export const MONSTERS = [ hooks: null, specials: [], description: 'A crusty growth clinging to the dungeon stone. Edible, if desperate.', + lootTable: 'drop:plant', }, // ── Nymph (tier 0, minDepth 3) — item thief ─────────────────────── { diff --git a/src/rules/data/shopStock.js b/src/rules/data/shopStock.js index bdee78dd..975fb689 100644 --- a/src/rules/data/shopStock.js +++ b/src/rules/data/shopStock.js @@ -157,6 +157,18 @@ export function generateAlchemyShopItem(world, rng) { { id: "potion_speed", weight: 5 }, { id: "potion_acid", weight: 4 }, { id: "potion_oil", weight: 4 }, + { id: "reagent_resin", weight: 10 }, + { id: "reagent_venom_gland", weight: 8 }, + { id: "reagent_bone_dust", weight: 7 }, + { id: "reagent_rune_fragment", weight: 7 }, + { id: "reagent_frost_core", weight: 6 }, + { id: "reagent_cursed_thread", weight: 6 }, + { id: "scroll_enchant_poison", weight: 3 }, + { id: "scroll_enchant_fire", weight: 3 }, + { id: "scroll_enchant_frost", weight: 3 }, + { id: "scroll_enchant_flame_ward", weight: 2 }, + { id: "scroll_enchant_venom_ward", weight: 2 }, + { id: "scroll_enchant_fortified", weight: 2 }, ]); if (!pick?.id) return stripPosition(world, createFrom(world, HealthPotion, {})); const itemId = createItemById(world, pick.id); diff --git a/src/rules/data/townfolk.js b/src/rules/data/townfolk.js index 52e1b154..4d2bf7ca 100644 --- a/src/rules/data/townfolk.js +++ b/src/rules/data/townfolk.js @@ -82,6 +82,14 @@ export const TOWNFOLK = Object.freeze({ maxHp: 30, dialogue: "A pinch of this, a drop of that... perfection takes patience.", }, + enchantress: { + name: "Enchantress", + identity: "townfolk_enchantress", + role: "enchantress", + speed: 2, + maxHp: 28, + dialogue: "Bring me reagents, gold, and a piece worth keeping. I'll give it a second life.", + }, fisher: { name: "Fisher", identity: "townfolk_fisher", diff --git a/src/rules/data/townfolkAmbientDialogue.js b/src/rules/data/townfolkAmbientDialogue.js index ef9fe23b..bd425a7f 100644 --- a/src/rules/data/townfolkAmbientDialogue.js +++ b/src/rules/data/townfolkAmbientDialogue.js @@ -135,6 +135,11 @@ const ROLE_GOSSIP = Object.freeze({ "the priest's asked for items I've never heard of before", "something's stirring the potions in their bottles at night", ]), + enchantress: Object.freeze([ + "the enchantress keeps asking after spider glands and grave dust", + "the apothecary's got a second lamp burning late into the night", + "folk say she can make a trinket remember fire", + ]), gem_vendor: Object.freeze([ "heard the adventurer's carrying something that glows", "the book vendor says there's demand for strange knowledge", @@ -318,6 +323,23 @@ const ROLE_LEXICON = Object.freeze({ "volatile stock goes bad exactly when people get desperate", ]), }), + enchantress: Object.freeze({ + title: "enchantress", + goods: Object.freeze(["binding scrolls", "warding vellum", "sealed charms", "enchanted gear"]), + worksites: Object.freeze(["the enchanting bench", "the reagent tray", "the binding table", "the warding lamp"]), + boasts: Object.freeze([ + "a good binding makes gear remember what it wants to be", + "reagents matter more than symbols; symbols just persuade them", + "I don't sell miracles, only durable agreements", + "if a ward slips, it wasn't bound honestly to begin with", + ]), + worries: Object.freeze([ + "grave dust is getting harder to find clean", + "too many hunters bring me venom that's already gone flat", + "cheap vellum ruins more work than dull knives ever could", + "one missing binder and the whole script peels off by morning", + ]), + }), gem_vendor: Object.freeze({ title: "gem merchant", goods: Object.freeze(["cut stones", "rough gems", "polished quartz", "tiny velvet rolls"]), diff --git a/src/rules/dialogues/townfolkDialogs.js b/src/rules/dialogues/townfolkDialogs.js index b2dd3962..c7b05a36 100644 --- a/src/rules/dialogues/townfolkDialogs.js +++ b/src/rules/dialogues/townfolkDialogs.js @@ -309,7 +309,7 @@ function barkeepQuest(world, actorId) { } for (const def of Object.values(TOWNFOLK)) { - if (def.role === "priest" || def.role === "barkeep") continue; + if (def.role === "priest" || def.role === "barkeep" || def.role === "enchantress") continue; registerDialog({ id: `townfolk:${def.role}`, start: "root", @@ -324,6 +324,68 @@ for (const def of Object.values(TOWNFOLK)) { }); } +registerDialog({ + id: "townfolk:enchantress", + start: "root", + presentation: "overlay", + nodes: { + root: { + text: (ctx) => ambientTownfolkText(TOWNFOLK.enchantress, ctx), + choices: [ + { + id: "open_services", + label: "Show me your enchanting services.", + emits: [{ + name: "enchanting:openRequest", + payload: (ctx) => ({ + actorId: ctx.actorId, + targetId: ctx.targetId, + title: "✧ Enchantress", + subtitle: "Bring themed reagents, gold, and the gear you want changed forever.", + }), + }], + close: true, + }, + { id: "ask_reagents", label: "What reagents are you after?", to: "reagents" }, + { id: "ask_work", label: "What bindings can you make?", to: "services" }, + { id: "leave", label: "Goodbye.", close: true }, + ], + }, + reagents: { + text: "Spider legs and venom glands for poisons. Ash, runes, and ember root for fire. Moonleaf, water, and frost cores for cold. Bone dust, resin, and cursed thread keep wards from slipping loose.", + choices: [ + { id: "services", label: "And the bindings?", to: "services" }, + { id: "open_services", label: "Let's start enchanting.", emits: [{ + name: "enchanting:openRequest", + payload: (ctx) => ({ + actorId: ctx.actorId, + targetId: ctx.targetId, + title: "✧ Enchantress", + subtitle: "Pick a binding, pay the gold, and I'll give you a scroll worth using.", + }), + }], close: true }, + { id: "leave", label: "Later.", close: true }, + ], + }, + services: { + text: "Venom for blades. Fire and frost for killing edges. Wards for armor, shields, rings, and amulets. I bind the scroll; you decide what piece earns it.", + choices: [ + { id: "open_services", label: "Make me a scroll.", emits: [{ + name: "enchanting:openRequest", + payload: (ctx) => ({ + actorId: ctx.actorId, + targetId: ctx.targetId, + title: "✧ Enchantress", + subtitle: "Choose the binding you want and I'll scribe the scroll if you've brought the price.", + }), + }], close: true }, + { id: "reagents", label: "Remind me of the materials.", to: "reagents" }, + { id: "leave", label: "That's enough for now.", close: true }, + ], + }, + }, +}); + registerDialog({ id: "townfolk:barkeep", start: "root", diff --git a/src/rules/environment/dungeon/populate.js b/src/rules/environment/dungeon/populate.js index b265dcba..232cad11 100644 --- a/src/rules/environment/dungeon/populate.js +++ b/src/rules/environment/dungeon/populate.js @@ -2445,6 +2445,18 @@ export function materializeSpawn(world, spawn) { shopkeeperId: id, }); } + } else if (def.role === "enchantress") { + world.add(id, Interactable, { + action: "openEnchantressServices", + params: { + dialogue: def.dialogue, + townfolkId: spawn.params.townfolkId, + dialogId: "townfolk:enchantress", + }, + }); + if (spawn.params.shopDoor) { + assignShopDoorKey(world, id, spawn.params.shopDoorRole || def.role, spawn.params.shopDoor); + } } else if (def.role === "gem_vendor") { world.add(id, Interactable, { action: "openGemVendor", diff --git a/src/rules/environment/dungeon/townPlacement.js b/src/rules/environment/dungeon/townPlacement.js index 5ad85f25..65650017 100644 --- a/src/rules/environment/dungeon/townPlacement.js +++ b/src/rules/environment/dungeon/townPlacement.js @@ -85,7 +85,7 @@ const BUILDING_PLANS = Object.freeze([ Object.freeze({ key: "tavern", district: "market_green", coreDx: -8, coreDy: -3, wants: ["flat"], roles: ["barkeep"] }), Object.freeze({ key: "general_store", district: "market_green", coreDx: -3, coreDy: 9, wants: ["flat"], roles: [] }), Object.freeze({ key: "smithy", district: "workshop_row", coreDx: 12, coreDy: -4, wants: ["mountain", "flat"], roles: ["smith"], rotations: FIXED_ROTATION }), - Object.freeze({ key: "apothecary", district: "workshop_row", coreDx: 4, coreDy: 13, wants: ["forest", "water"], roles: ["alchemist"] }), + Object.freeze({ key: "apothecary", district: "workshop_row", coreDx: 4, coreDy: 13, wants: ["forest", "water"], roles: ["alchemist", "enchantress"] }), Object.freeze({ key: "gem_store", district: "workshop_row", coreDx: -1, coreDy: 12, wants: ["flat"], roles: ["gem_vendor"] }), Object.freeze({ key: "book_shop", district: "civic_core", coreDx: 7, coreDy: -10, wants: ["flat"], roles: ["book_vendor"] }), Object.freeze({ key: "church", district: "churchyard", coreDx: -12, coreDy: 8, wants: ["quiet", "flat"], roles: ["priest"] }), @@ -423,16 +423,18 @@ function hashKey(key) { } function addTownfolkForBuilding(chunks, building, roles, tavernDoor) { - const home = building.waypoints.resident_home - || building.waypoints.vendor_work - || building.waypoints.farmer_work - || building.door; - const work = building.waypoints.vendor_work - || building.waypoints.farmer_work - || building.waypoints.shop_door - || building.waypoints.front_door - || building.door; for (const role of roles) { + const home = (role === "enchantress" ? (building.waypoints.enchantress_work || building.waypoints.vendor_work) : null) + || building.waypoints.resident_home + || building.waypoints.vendor_work + || building.waypoints.farmer_work + || building.door; + const work = (role === "enchantress" ? (building.waypoints.enchantress_work || building.waypoints.vendor_work) : null) + || building.waypoints.vendor_work + || building.waypoints.farmer_work + || building.waypoints.shop_door + || building.waypoints.front_door + || building.door; addChunkSpawn(chunks, work.x, work.y, "townfolk", { townfolkId: role, homeX: home.x, diff --git a/src/rules/systems/aiTownfolkSystem.js b/src/rules/systems/aiTownfolkSystem.js index bf0f6f86..d65a7a06 100644 --- a/src/rules/systems/aiTownfolkSystem.js +++ b/src/rules/systems/aiTownfolkSystem.js @@ -655,6 +655,12 @@ function handleIdle(world, id, pos, job) { job.workSiteKind = "brew"; break; } + case TOWNFOLK_ROLES.enchantress: { + job.targetX = job.workX; + job.targetY = job.workY; + job.workSiteKind = "tend_stall"; + break; + } case TOWNFOLK_ROLES.fisher: { const spot = findFishableShoreSpot(job.workX, job.workY, WORK_RANGE); if (!spot) { @@ -1242,6 +1248,8 @@ function getRoleWorkTarget(world, job) { } return { x: job.workAuxX, y: job.workAuxY, kind: "stock_shelves", state: TOWNFOLK_STATES.working, radius: 1 }; } + case TOWNFOLK_ROLES.enchantress: + return { x: job.workX, y: job.workY, kind: "tend_stall", state: TOWNFOLK_STATES.working, radius: 0 }; case TOWNFOLK_ROLES.fisher: { const storage = _cachedStorage; const tavernPos = getEntityPosition(world, storage.tavern); diff --git a/tests/enchantingBench.test.mjs b/tests/enchantingBench.test.mjs index a0e07beb..d8f5ffb5 100644 --- a/tests/enchantingBench.test.mjs +++ b/tests/enchantingBench.test.mjs @@ -36,10 +36,10 @@ function addCatalogItem(world, actor, identity, count = 1) { Deno.test("enchanting bench crafts a poison enchant scroll from reagents and gold", () => { const world = new World({ seed: 1201 }); const actor = makeActor(world); - addCatalogItem(world, actor, "reagent_venom_frond", 2); - addCatalogItem(world, actor, "reagent_thorn_pod", 1); - addCatalogItem(world, actor, "potion_oil", 1); - addStackedGold(world, actor, 55); + addCatalogItem(world, actor, "reagent_spider_leg", 2); + addCatalogItem(world, actor, "reagent_venom_gland", 1); + addCatalogItem(world, actor, "reagent_resin", 1); + addStackedGold(world, actor, 65); const bench = createFrom(world, EnchantingBench, { x: 4, y: 4 }); const crafted = []; @@ -55,22 +55,27 @@ Deno.test("enchanting bench crafts a poison enchant scroll from reagents and gol assertEquals(crafted.length, 1); assertEquals(crafted[0]?.outputIdentity, "scroll_enchant_poison"); assertEquals(getStackCount(world, actor, "gold"), 0); - assertEquals(getStackCount(world, actor, "potion_oil"), 0); - assertEquals(getStackCount(world, actor, "reagent_venom_frond"), 0); + assertEquals(getStackCount(world, actor, "reagent_spider_leg"), 0); + assertEquals(getStackCount(world, actor, "reagent_venom_gland"), 0); + assertEquals(getStackCount(world, actor, "reagent_resin"), 0); assert( inventoryItems(world, actor).some((id) => world.get(id, ItemInfo)?.type === "scroll" && crafted[0]?.itemId === id), "actor should receive the crafted enchant scroll", ); }); -Deno.test("enchant scroll applies a persistent affix and rejects duplicate applications", () => { +Deno.test("enchant scroll applies persistent affixes, supports accessories, and rejects invalid repeats", () => { const world = new World({ seed: 1202 }); const actor = makeActor(world); const weapon = createItemById(world, "dagger_quick"); const firstScroll = createItemById(world, "scroll_enchant_fire"); - assert(weapon > 0 && firstScroll > 0, "required test items should be creatable"); + const amulet = createItemById(world, "amulet_guarded"); + const wardScroll = createItemById(world, "scroll_enchant_flame_ward"); + assert(weapon > 0 && firstScroll > 0 && amulet > 0 && wardScroll > 0, "required test items should be creatable"); addToInventory(world, actor, weapon); + addToInventory(world, actor, amulet); addToInventory(world, actor, firstScroll); + addToInventory(world, actor, wardScroll); const results = []; const cancelled = []; @@ -86,14 +91,42 @@ Deno.test("enchant scroll applies a persistent affix and rejects duplicate appli assert(!world.isAlive(firstScroll), "successful enchant should consume the scroll"); assert((world.get(weapon, ItemInfo)?.affixes || []).includes("firestorm1")); + world.add(actor, ApplyIntent, { itemId: wardScroll, targetItemId: amulet }); + applySystem(world); + + assertEquals(results.length, 2); + assertEquals(results[1]?.ok, true); + assert((world.get(amulet, ItemInfo)?.affixes || []).includes("fireWard1")); + const secondScroll = createItemById(world, "scroll_enchant_fire"); addToInventory(world, actor, secondScroll); world.add(actor, ApplyIntent, { itemId: secondScroll, targetItemId: weapon }); applySystem(world); - assertEquals(results.length, 2); - assertEquals(results[1]?.ok, false); + assertEquals(results.length, 3); + assertEquals(results[2]?.ok, false); assertEquals(cancelled.length, 1); assertEquals(cancelled[0]?.code, "ENCHANT_ALREADY_PRESENT"); assert(world.isAlive(secondScroll), "duplicate enchant should not consume the scroll"); }); + +Deno.test("fire weapon scroll rejects incompatible accessory targets", () => { + const world = new World({ seed: 1203 }); + const actor = makeActor(world); + const amulet = createItemById(world, "amulet_guarded"); + const fireScroll = createItemById(world, "scroll_enchant_fire"); + assert(amulet > 0 && fireScroll > 0); + addToInventory(world, actor, amulet); + addToInventory(world, actor, fireScroll); + + const results = []; + world.on("interaction:result", (ev) => results.push(ev)); + + world.add(actor, ApplyIntent, { itemId: fireScroll, targetItemId: amulet }); + applySystem(world); + + assertEquals(results.length, 1); + assertEquals(results[0]?.metrics?.payloadMatched, false); + assertEquals((world.get(amulet, ItemInfo)?.affixes || []).includes("firestorm1"), false); + assert(world.isAlive(fireScroll), "invalid target should not consume the scroll"); +}); diff --git a/tests/enchantingContent.test.mjs b/tests/enchantingContent.test.mjs new file mode 100644 index 00000000..3180343d --- /dev/null +++ b/tests/enchantingContent.test.mjs @@ -0,0 +1,23 @@ +import { assert, assertEquals } from "jsr:@std/assert"; +import { getDialog } from "../src/rules/dialogues/registry.js"; +import { LOOT_TABLES } from "../src/rules/data/lootTables.js"; +import "../src/rules/dialogues/townfolkDialogs.js"; + +Deno.test("enchantress dialog exposes enchanting services as a dedicated NPC", () => { + const dialog = getDialog("townfolk:enchantress"); + assert(dialog); + const rootChoices = dialog?.nodes?.root?.choices || []; + assert(rootChoices.some((choice) => choice.id === "open_services")); + assert(rootChoices.some((choice) => choice.id === "ask_reagents")); +}); + +Deno.test("loot tables define thematic enchanting reagent families", () => { + assert(LOOT_TABLES["drop:spider"]); + assert(LOOT_TABLES["drop:witch"]); + assert(LOOT_TABLES["drop:plant"]); + const beastEntries = LOOT_TABLES["sub:reagents_beast"]?.entries || []; + assert(beastEntries.some((entry) => entry.itemId === "reagent_beast_claw")); + const undeadEntries = LOOT_TABLES["sub:reagents_undead"]?.entries || []; + assert(undeadEntries.some((entry) => entry.itemId === "reagent_bone_dust")); + assertEquals(Array.isArray(LOOT_TABLES["drop:witch"]?.entries), true); +}); From a1db2ad0a15ab4b47bb7a8ef4cf3291c15a2d754 Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Wed, 29 Apr 2026 00:00:00 -0400 Subject: [PATCH 6/7] ENCHANTING: followup legibility and fixes. --- src/rules/content/enchanting/benchGame.js | 21 ++++- .../content/enchanting/enchantCatalog.js | 93 ++++++++++++++++++- src/rules/data/affixes.js | 6 +- src/rules/data/itemCatalogMagic.js | 32 +++++-- src/rules/data/lootTables.js | 4 +- src/rules/data/shopStock.js | 6 -- tests/enchantingBench.test.mjs | 62 ++++++++++++- tests/enchantingContent.test.mjs | 15 +++ 8 files changed, 213 insertions(+), 26 deletions(-) diff --git a/src/rules/content/enchanting/benchGame.js b/src/rules/content/enchanting/benchGame.js index 1a92dc75..30697a8c 100644 --- a/src/rules/content/enchanting/benchGame.js +++ b/src/rules/content/enchanting/benchGame.js @@ -1,6 +1,6 @@ import { Inventory } from "../../components/Inventory.js"; import { Position } from "../../components/Position.js"; -import { createItemById } from "../../utils/itemFactory.js"; +import { createItemById, isValidItemId } from "../../utils/itemFactory.js"; import { addToInventory, consumeFromStack, getStackCount } from "../../utils/inventoryFacade.js"; import { ENCHANTING_INGREDIENTS, getEnchantScrollDef, listEnchantRecipeDefs } from "./enchantCatalog.js"; @@ -72,7 +72,7 @@ export function emitEnchantingBenchOpen(world, actor, targetId, options = {}) { const ingredients = countEnchantingIngredients(world, actor); const recipes = listEnchantRecipeDefs().map((recipe) => ({ ...recipe, - metadata: { tier: 1, rarity: "magic" }, + metadata: { ...(recipe.metadata || { tier: 1, rarity: "magic" }) }, canCraft: hasEnoughIngredients(ingredients, recipe.requirements || {}), })); world.emit?.("enchanting:open", { @@ -108,6 +108,16 @@ export function craftAtEnchantingBench(world, actor, targetId, recipeKey, option return false; } + if (!isValidItemId(recipe.outputIdentity)) { + world.emit?.("enchanting:result", { + actor, + targetId, + result: "craft_failed", + recipeKey: recipe.key, + }); + return false; + } + const ingredients = countEnchantingIngredients(world, actor); if (!hasEnoughIngredients(ingredients, recipe.requirements || {})) { const missing = {}; @@ -159,7 +169,12 @@ export function craftAtEnchantingBench(world, actor, targetId, recipeKey, option outputName: recipe.outputName, enchantType: recipe.enchantType, affixId: recipe.affixId, - metadata: { tier: 1, rarity: "magic" }, + magnitude: recipe.magnitude, + proc: recipe.proc, + duration: recipe.duration, + allowedSlots: Array.isArray(recipe.allowedSlots) ? recipe.allowedSlots.slice() : [], + runtime: { ...(recipe.runtime || {}) }, + metadata: { ...(recipe.metadata || { tier: 1, rarity: "magic" }) }, requirements: { ...(recipe.requirements || {}) }, }); emitEnchantingBenchOpen(world, actor, targetId, options); diff --git a/src/rules/content/enchanting/enchantCatalog.js b/src/rules/content/enchanting/enchantCatalog.js index bb431489..ae0d50b1 100644 --- a/src/rules/content/enchanting/enchantCatalog.js +++ b/src/rules/content/enchanting/enchantCatalog.js @@ -18,6 +18,51 @@ export const ENCHANTING_INGREDIENTS = Object.freeze({ gold: Object.freeze({ identity: "gold", label: "Gold" }), }); +export const ENCHANTABLE_GEAR_SLOTS = Object.freeze([ + "weapon", + "armor", + "head", + "neck", + "belt", + "gloves", + "offhand", + "ring", + "ring1", + "ring2", + "legs", + "feet", + "ranged", +]); + +export const DEFENSIVE_ENCHANT_SLOTS = Object.freeze([ + "armor", + "head", + "neck", + "belt", + "gloves", + "offhand", + "ring", + "ring1", + "ring2", + "legs", + "feet", + "ranged", +]); + +export function normalizeEnchantSlot(slot) { + const key = String(slot || "").trim().toLowerCase(); + if (key === "ring1" || key === "ring2") return "ring"; + if (key === "shield") return "offhand"; + return key; +} + +export function enchantDefSupportsSlot(def, slot) { + const normalized = normalizeEnchantSlot(slot); + if (!normalized || normalized === "ammo") return false; + const allowed = Array.isArray(def?.allowedSlots) ? def.allowedSlots : []; + return allowed.map(normalizeEnchantSlot).includes(normalized); +} + export const ENCHANT_SCROLL_DEFS = Object.freeze([ Object.freeze({ itemId: "scroll_enchant_poison", @@ -25,6 +70,12 @@ export const ENCHANT_SCROLL_DEFS = Object.freeze([ name: "Scroll of Venom Binding", enchantType: "poison", affixId: "venomous1", + magnitude: 2, + proc: "on hit", + duration: 4, + allowedSlots: Object.freeze(["weapon"]), + runtime: Object.freeze({ affixId: "venomous1", procPackageId: null }), + metadata: Object.freeze({ tier: 1, rarity: "magic", role: "source:poison/carrier:weapon/binder:resin" }), description: "Apply to a weapon to bind a persistent venomous enchantment.", effectSummary: "On hit, weapon strikes can poison enemies.", detail: "Strikes from the enchanted gear can poison your enemies.", @@ -38,6 +89,12 @@ export const ENCHANT_SCROLL_DEFS = Object.freeze([ name: "Scroll of Firestorm Binding", enchantType: "fire", affixId: "firestorm1", + magnitude: 2, + proc: "on hit", + duration: 3, + allowedSlots: Object.freeze(["weapon"]), + runtime: Object.freeze({ affixId: "firestorm1", procPackageId: null }), + metadata: Object.freeze({ tier: 1, rarity: "magic", role: "source:fire/carrier:oil/binder:rune" }), description: "Apply to a weapon to bind a persistent firestorm enchantment.", effectSummary: "On hit, weapon strikes can kindle lingering fire damage.", detail: "Strikes from the enchanted gear can ignite lingering fire.", @@ -51,6 +108,12 @@ export const ENCHANT_SCROLL_DEFS = Object.freeze([ name: "Scroll of Frost Binding", enchantType: "frost", affixId: "frostbite1", + magnitude: 1, + proc: "on hit", + duration: 2, + allowedSlots: Object.freeze(["weapon"]), + runtime: Object.freeze({ affixId: "frostbite1", procPackageId: null }), + metadata: Object.freeze({ tier: 1, rarity: "magic", role: "source:frost/carrier:water/binder:rune" }), description: "Apply to a weapon to bind a persistent frostbite enchantment.", effectSummary: "On hit, weapon strikes can chill enemies with frost.", detail: "Strikes from the enchanted gear can chill foes with frost.", @@ -64,7 +127,13 @@ export const ENCHANT_SCROLL_DEFS = Object.freeze([ name: "Scroll of Flame Ward Binding", enchantType: "fire ward", affixId: "fireWard1", - description: "Apply to armor, offhand gear, or an amulet to bind a persistent flame ward.", + magnitude: 0.15, + proc: "passive", + duration: 0, + allowedSlots: DEFENSIVE_ENCHANT_SLOTS, + runtime: Object.freeze({ affixId: "fireWard1", procPackageId: null }), + metadata: Object.freeze({ tier: 1, rarity: "magic", role: "source:fire/carrier:ash/binder:resin" }), + description: "Apply to armor, accessories, offhand gear, or ranged gear to bind a persistent flame ward.", effectSummary: "Adds enduring fire resistance to defensive gear.", detail: "The binding settles into the gear as a steady ward against flame.", stationTitle: "✧ Enchantress's Satchel", @@ -77,7 +146,13 @@ export const ENCHANT_SCROLL_DEFS = Object.freeze([ name: "Scroll of Venom Ward Binding", enchantType: "venom ward", affixId: "poisonWard1", - description: "Apply to armor, rings, or an amulet to bind a persistent venom ward.", + magnitude: 0.15, + proc: "passive", + duration: 0, + allowedSlots: DEFENSIVE_ENCHANT_SLOTS, + runtime: Object.freeze({ affixId: "poisonWard1", procPackageId: null }), + metadata: Object.freeze({ tier: 1, rarity: "magic", role: "source:poison/carrier:thread/binder:gland" }), + description: "Apply to armor, accessories, offhand gear, or ranged gear to bind a persistent venom ward.", effectSummary: "Adds enduring poison resistance to defensive gear.", detail: "The script stiffens into a bitter ward against poison.", stationTitle: "✧ Enchantress's Satchel", @@ -90,7 +165,13 @@ export const ENCHANT_SCROLL_DEFS = Object.freeze([ name: "Scroll of Fortified Binding", enchantType: "fortified", affixId: "kineticWard1", - description: "Apply to armor, offhand gear, or an amulet to bind a persistent fortified ward.", + magnitude: 2, + proc: "passive", + duration: 0, + allowedSlots: DEFENSIVE_ENCHANT_SLOTS, + runtime: Object.freeze({ affixId: "kineticWard1", procPackageId: null }), + metadata: Object.freeze({ tier: 1, rarity: "magic", role: "source:impact/carrier:claw/binder:bone" }), + description: "Apply to armor, accessories, offhand gear, or ranged gear to bind a persistent fortified ward.", effectSummary: "Adds enduring impact resistance to heavy gear.", detail: "The page hardens the gear into a patient, stubborn bulwark.", stationTitle: "✧ Enchantress's Satchel", @@ -117,6 +198,12 @@ export function listEnchantRecipeDefs() { outputName: def.name, enchantType: def.enchantType, affixId: def.affixId, + magnitude: def.magnitude, + proc: def.proc, + duration: def.duration, + allowedSlots: Array.isArray(def.allowedSlots) ? def.allowedSlots.slice() : [], + runtime: { ...(def.runtime || {}) }, + metadata: { ...(def.metadata || { tier: 1, rarity: "magic" }) }, effectSummary: def.effectSummary, flavor: def.flavor, requirements: { ...(def.requirements || {}) }, diff --git a/src/rules/data/affixes.js b/src/rules/data/affixes.js index 8fbfad62..22c94c3a 100644 --- a/src/rules/data/affixes.js +++ b/src/rules/data/affixes.js @@ -761,9 +761,9 @@ export function unregisterAffixDefinition(id) { ["guard1", { name: "Guarded", slots: ["armor"], weight: 25, passiveRefs: [AFFIX_GUARD] }], ["life1", { name: "Healthy", slots: ["armor", "ring"], weight: 22, passiveRefs: [AFFIX_LIFE] }], ["attuned1", { name: "Attuned", slots: ["ring"], weight: 20, passiveRefs: [AFFIX_ATTUNED] }], - ["fireWard1", { name: "Flame Ward", slots: ["armor", "offhand", "neck"], weight: 18, passiveRefs: [AFFIX_FIRE_WARD] }], - ["poisonWard1", { name: "Venom Ward", slots: ["armor", "ring", "neck"], weight: 18, passiveRefs: [AFFIX_POISON_WARD] }], - ["kineticWard1", { name: "Fortified", slots: ["armor", "offhand", "neck"], weight: 15, passiveRefs: [AFFIX_KINETIC_WARD] }], + ["fireWard1", { name: "Flame Ward", slots: ["armor", "head", "neck", "belt", "gloves", "offhand", "ring", "legs", "feet", "ranged"], weight: 18, passiveRefs: [AFFIX_FIRE_WARD] }], + ["poisonWard1", { name: "Venom Ward", slots: ["armor", "head", "neck", "belt", "gloves", "offhand", "ring", "legs", "feet", "ranged"], weight: 18, passiveRefs: [AFFIX_POISON_WARD] }], + ["kineticWard1", { name: "Fortified", slots: ["armor", "head", "neck", "belt", "gloves", "offhand", "ring", "legs", "feet", "ranged"], weight: 15, passiveRefs: [AFFIX_KINETIC_WARD] }], ["caustic1", { name: "Caustic", slots: ["weapon"], weight: 16, elementTint: ELEMENT_TINT_ACID, triggerScripts: { onHit: [AFFIX_CAUSTIC] } }], ["capacitive1", { name: "Capacitive", slots: ["weapon"], weight: 15, elementTint: ELEMENT_TINT_ELECTRIC, triggerScripts: { onHit: [AFFIX_CAPACITIVE] } }], ["insulated1", { name: "Insulated", slots: ["armor", "offhand"], weight: 16, passiveRefs: [AFFIX_INSULATED] }], diff --git a/src/rules/data/itemCatalogMagic.js b/src/rules/data/itemCatalogMagic.js index 89fff7db..65b69b07 100644 --- a/src/rules/data/itemCatalogMagic.js +++ b/src/rules/data/itemCatalogMagic.js @@ -32,7 +32,12 @@ import { getPassiveBonuses } from "../utils/passiveBonuses.js"; import { attachDerivedExpression, exprAddConst } from "../utils/statProcAuthoring.js"; import { resolveItemCooldownRemaining } from "../utils/itemCooldowns.js"; import { affixSupportsSlot } from "./affixes.js"; -import { ENCHANT_SCROLL_DEFS, getEnchantScrollDef } from "../content/enchanting/enchantCatalog.js"; +import { + enchantDefSupportsSlot, + ENCHANT_SCROLL_DEFS, + getEnchantScrollDef, + normalizeEnchantSlot, +} from "../content/enchanting/enchantCatalog.js"; /** * Destroy any existing DerivedExpression entity owned by an active effect @@ -53,11 +58,12 @@ function cleanupPriorExprEntity(ctx, targetId, effectKey) { function canEnchantScrollTarget(state) { const targetInfo = state?.targetInfo; if (!targetInfo || String(targetInfo.type || "") !== "equip") return false; - const slot = String(targetInfo.slot || "").toLowerCase(); + const slot = normalizeEnchantSlot(targetInfo.slot); if (!slot || slot === "ammo") return false; const scrollDef = getEnchantScrollDef(state?.toolIdentity || state?.toolId || ""); - if (!scrollDef?.affixId) return false; - return affixSupportsSlot(scrollDef.affixId, slot); + const runtimeAffixId = scrollDef?.runtime?.affixId || scrollDef?.affixId; + if (!runtimeAffixId) return false; + return enchantDefSupportsSlot(scrollDef, slot) && affixSupportsSlot(runtimeAffixId, slot); } function createEnchantScrollUseHint(message) { @@ -71,11 +77,12 @@ function createEnchantScrollUseHint(message) { }); } -function createGearEnchantDipHook({ affixId, enchantType, enchantLabel, detail }) { +function createGearEnchantDipHook({ affixId, enchantType, enchantLabel, detail, allowedSlots, magnitude, proc, duration, metadata }) { const resolvedAffixId = String(affixId || "").trim(); const resolvedType = String(enchantType || "").trim().toLowerCase(); const resolvedLabel = String(enchantLabel || resolvedType || "enchant"); const resolvedDetail = String(detail || ""); + const scrollDef = { affixId: resolvedAffixId, allowedSlots: Array.isArray(allowedSlots) ? allowedSlots.slice() : [] }; return (ctx, state) => { const targetId = Number(state?.targetId || 0) | 0; if (!(targetId > 0)) { @@ -96,8 +103,8 @@ function createGearEnchantDipHook({ affixId, enchantType, enchantLabel, detail } return { applied: false, consumedTool: false, resultType: "nothing" }; } const targetName = resolveApplyTargetName(ctx, state, "gear"); - const slot = String(info.slot || "").toLowerCase(); - if (!affixSupportsSlot(resolvedAffixId, slot)) { + const slot = normalizeEnchantSlot(info.slot); + if (!enchantDefSupportsSlot(scrollDef, slot) || !affixSupportsSlot(resolvedAffixId, slot)) { ctx.cancel({ code: "ENCHANT_INVALID_SLOT", message: `${targetName} cannot hold ${resolvedLabel}.`, @@ -123,6 +130,10 @@ function createGearEnchantDipHook({ affixId, enchantType, enchantLabel, detail } type: "gear_enchant", enchantType: resolvedType, affixId: resolvedAffixId, + magnitude, + proc, + duration, + metadata: { ...(metadata || {}) }, message: `You bind ${resolvedLabel} into ${targetName}.${resolvedDetail ? ` ${resolvedDetail}` : ""}`, }, }); @@ -146,10 +157,15 @@ const ENCHANT_SCROLL_ITEMS = Object.fromEntries( hooks: { can_dip_target: canEnchantScrollTarget, on_dip: createGearEnchantDipHook({ - affixId: def.affixId, + affixId: def.runtime?.affixId || def.affixId, enchantType: def.enchantType, enchantLabel: def.name.replace(/^Scroll of /, "").replace(/ Binding$/, ""), detail: def.detail, + allowedSlots: def.allowedSlots, + magnitude: def.magnitude, + proc: def.proc, + duration: def.duration, + metadata: def.metadata, }), on_use: createEnchantScrollUseHint(`Choose a piece of gear for ${def.name.toLowerCase()}.`), }, diff --git a/src/rules/data/lootTables.js b/src/rules/data/lootTables.js index d6cb9346..67d53af8 100644 --- a/src/rules/data/lootTables.js +++ b/src/rules/data/lootTables.js @@ -732,8 +732,8 @@ export const LOOT_TABLES = { { type: "table", weight: 10, tableId: "sub:scrolls" }, { type: "table", weight: 8, tableId: "sub:potions" }, { type: "item", weight: 8, itemId: "scroll_remove_curse" }, - { type: "item", weight: 6, itemId: "scroll_enchant_venom_ward" }, - { type: "item", weight: 6, itemId: "scroll_enchant_frost" }, + { type: "item", weight: 6, itemId: "reagent_cursed_thread" }, + { type: "item", weight: 6, itemId: "reagent_frost_core" }, ], }, diff --git a/src/rules/data/shopStock.js b/src/rules/data/shopStock.js index 975fb689..416a7884 100644 --- a/src/rules/data/shopStock.js +++ b/src/rules/data/shopStock.js @@ -163,12 +163,6 @@ export function generateAlchemyShopItem(world, rng) { { id: "reagent_rune_fragment", weight: 7 }, { id: "reagent_frost_core", weight: 6 }, { id: "reagent_cursed_thread", weight: 6 }, - { id: "scroll_enchant_poison", weight: 3 }, - { id: "scroll_enchant_fire", weight: 3 }, - { id: "scroll_enchant_frost", weight: 3 }, - { id: "scroll_enchant_flame_ward", weight: 2 }, - { id: "scroll_enchant_venom_ward", weight: 2 }, - { id: "scroll_enchant_fortified", weight: 2 }, ]); if (!pick?.id) return stripPosition(world, createFrom(world, HealthPotion, {})); const itemId = createItemById(world, pick.id); diff --git a/tests/enchantingBench.test.mjs b/tests/enchantingBench.test.mjs index d8f5ffb5..979ea1bb 100644 --- a/tests/enchantingBench.test.mjs +++ b/tests/enchantingBench.test.mjs @@ -4,6 +4,7 @@ import { EnchantingBench } from "../src/rules/archetypes/Overworld.js"; import { GoldStack } from "../src/rules/archetypes/Items.js"; import { Inventory } from "../src/rules/components/Inventory.js"; import { ItemInfo } from "../src/rules/components/ItemInfo.js"; +import { NON_AMMO_GEAR_SLOTS } from "../src/rules/components/Equipment.js"; import { InteractIntent } from "../src/rules/components/Intents/InteractIntent.js"; import { ApplyIntent } from "../src/rules/components/Intents/ApplyIntent.js"; import { interactionSystem } from "../src/rules/systems/interactionSystem.js"; @@ -14,7 +15,7 @@ import "../src/rules/data/affixes.js"; function makeActor(world) { const actor = world.create(); - world.add(actor, Inventory, { items: [], capacity: 20, weightLimit: null }); + world.add(actor, Inventory, { items: [], capacity: 50, weightLimit: null }); return actor; } @@ -130,3 +131,62 @@ Deno.test("fire weapon scroll rejects incompatible accessory targets", () => { assertEquals((world.get(amulet, ItemInfo)?.affixes || []).includes("firestorm1"), false); assert(world.isAlive(fireScroll), "invalid target should not consume the scroll"); }); + +Deno.test("enchant paths cover every non-ammo gear slot", () => { + const slotFixtures = { + weapon: { itemId: "dagger_quick", scrollId: "scroll_enchant_fire", affixId: "firestorm1" }, + armor: { itemId: "leather_armor", scrollId: "scroll_enchant_fortified", affixId: "kineticWard1" }, + head: { itemId: "helm_iron", scrollId: "scroll_enchant_fortified", affixId: "kineticWard1" }, + neck: { itemId: "amulet_guarded", scrollId: "scroll_enchant_fortified", affixId: "kineticWard1" }, + belt: { itemId: "belt_leather", scrollId: "scroll_enchant_fortified", affixId: "kineticWard1" }, + gloves: { itemId: "gloves_leather", scrollId: "scroll_enchant_fortified", affixId: "kineticWard1" }, + offhand: { itemId: "shield_wood", scrollId: "scroll_enchant_fortified", affixId: "kineticWard1" }, + ring1: { itemId: "ring_copper", scrollId: "scroll_enchant_fortified", affixId: "kineticWard1", forceSlot: "ring1" }, + ring2: { itemId: "ring_copper", scrollId: "scroll_enchant_fortified", affixId: "kineticWard1", forceSlot: "ring2" }, + legs: { itemId: "leggings_leather", scrollId: "scroll_enchant_fortified", affixId: "kineticWard1" }, + feet: { itemId: "boots_leather", scrollId: "scroll_enchant_fortified", affixId: "kineticWard1" }, + ranged: { itemId: "bow_short", scrollId: "scroll_enchant_fortified", affixId: "kineticWard1" }, + }; + + for (const slot of NON_AMMO_GEAR_SLOTS) { + assert(slotFixtures[slot], `missing enchant coverage fixture for ${slot}`); + } + + const world = new World({ seed: 1204 }); + const actor = makeActor(world); + + for (const slot of NON_AMMO_GEAR_SLOTS) { + const fixture = slotFixtures[slot]; + const target = createItemById(world, fixture.itemId); + const scroll = createItemById(world, fixture.scrollId); + assert(target > 0 && scroll > 0, `required test items should be creatable for ${slot}`); + if (fixture.forceSlot) { + world.get(target, ItemInfo).slot = fixture.forceSlot; + } + addToInventory(world, actor, target); + addToInventory(world, actor, scroll); + + world.add(actor, ApplyIntent, { itemId: scroll, targetItemId: target }); + applySystem(world); + + assert((world.get(target, ItemInfo)?.affixes || []).includes(fixture.affixId), `${slot} should accept ${fixture.scrollId}`); + assert(!world.isAlive(scroll), `${slot} enchant should consume the scroll`); + } +}); + +Deno.test("slot normalization lets shield-labeled gear accept offhand enchants", () => { + const world = new World({ seed: 1205 }); + const actor = makeActor(world); + const shield = createItemById(world, "shield_wood"); + const scroll = createItemById(world, "scroll_enchant_fortified"); + assert(shield > 0 && scroll > 0); + world.get(shield, ItemInfo).slot = "shield"; + addToInventory(world, actor, shield); + addToInventory(world, actor, scroll); + + world.add(actor, ApplyIntent, { itemId: scroll, targetItemId: shield }); + applySystem(world); + + assert((world.get(shield, ItemInfo)?.affixes || []).includes("kineticWard1")); + assert(!world.isAlive(scroll)); +}); diff --git a/tests/enchantingContent.test.mjs b/tests/enchantingContent.test.mjs index 3180343d..205b166e 100644 --- a/tests/enchantingContent.test.mjs +++ b/tests/enchantingContent.test.mjs @@ -1,6 +1,9 @@ import { assert, assertEquals } from "jsr:@std/assert"; +import { World } from "../src/lib/ecs-js/index.js"; +import { NamedIdentity } from "../src/rules/components/NamedIdentity.js"; import { getDialog } from "../src/rules/dialogues/registry.js"; import { LOOT_TABLES } from "../src/rules/data/lootTables.js"; +import { generateAlchemyShopItem } from "../src/rules/data/shopStock.js"; import "../src/rules/dialogues/townfolkDialogs.js"; Deno.test("enchantress dialog exposes enchanting services as a dedicated NPC", () => { @@ -21,3 +24,15 @@ Deno.test("loot tables define thematic enchanting reagent families", () => { assert(undeadEntries.some((entry) => entry.itemId === "reagent_bone_dust")); assertEquals(Array.isArray(LOOT_TABLES["drop:witch"]?.entries), true); }); + +Deno.test("loot and alchemy shops bias toward reagents instead of finished enchant scrolls", () => { + assertEquals(JSON.stringify(LOOT_TABLES).includes("scroll_enchant_"), false); + + const world = new World({ seed: 1210 }); + const rng = { next: () => 0.99 }; + for (let i = 0; i < 24; i++) { + const itemId = generateAlchemyShopItem(world, rng); + const identity = world.get(itemId, NamedIdentity)?.identity || ""; + assert(!identity.startsWith("scroll_enchant_"), `alchemy shop generated finished enchant scroll ${identity}`); + } +}); From 12f4b18928a227d1b7c693192e0f3f91e540dfa4 Mon Sep 17 00:00:00 2001 From: Pete Jensen Date: Wed, 29 Apr 2026 00:00:00 -0400 Subject: [PATCH 7/7] ENCHANTING: adjusting test ++ better isolation --- VERSION | 2 +- tests/enchantingBench.test.mjs | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/VERSION b/VERSION index acd0c0ac..68d67ea7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.818 +0.7.819 diff --git a/tests/enchantingBench.test.mjs b/tests/enchantingBench.test.mjs index 979ea1bb..78d8b6f2 100644 --- a/tests/enchantingBench.test.mjs +++ b/tests/enchantingBench.test.mjs @@ -55,6 +55,11 @@ Deno.test("enchanting bench crafts a poison enchant scroll from reagents and gol assertEquals(crafted.length, 1); assertEquals(crafted[0]?.outputIdentity, "scroll_enchant_poison"); + assertEquals(crafted[0]?.magnitude, 2); + assertEquals(crafted[0]?.proc, "on hit"); + assertEquals(crafted[0]?.duration, 4); + assertEquals(crafted[0]?.runtime?.affixId, "venomous1"); + assertEquals(crafted[0]?.metadata?.rarity, "magic"); assertEquals(getStackCount(world, actor, "gold"), 0); assertEquals(getStackCount(world, actor, "reagent_spider_leg"), 0); assertEquals(getStackCount(world, actor, "reagent_venom_gland"), 0); @@ -152,10 +157,9 @@ Deno.test("enchant paths cover every non-ammo gear slot", () => { assert(slotFixtures[slot], `missing enchant coverage fixture for ${slot}`); } - const world = new World({ seed: 1204 }); - const actor = makeActor(world); - for (const slot of NON_AMMO_GEAR_SLOTS) { + const world = new World({ seed: 1204 }); + const actor = makeActor(world); const fixture = slotFixtures[slot]; const target = createItemById(world, fixture.itemId); const scroll = createItemById(world, fixture.scrollId);