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/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..5be4787c --- /dev/null +++ b/src/display/ui/enchantingBenchOverlay.js @@ -0,0 +1,190 @@ +const INGREDIENT_LABELS = Object.freeze({ + emberRoot: "Ember Root", + 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", +}); + +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 = String(state?.title || "✧ Enchanting Bench"); + Object.assign(title.style, { + fontWeight: "bold", + marginBottom: "8px", + color: "#d9c3ff", + fontSize: "18px", + }); + el.appendChild(title); + + const subtitle = document.createElement("div"); + 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", + 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..729055bc 100644 --- a/src/display/ui/inventoryOverlay.js +++ b/src/display/ui/inventoryOverlay.js @@ -431,6 +431,10 @@ export function renderInventory(panel, items, ground, slotFilter = '', scrollOfI return; } if (actionKey === 'use') { + if (getInventoryDefaultAction(it) === 'apply') { + 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..5f7b6bea 100644 --- a/src/display/ui/inventoryUtils.js +++ b/src/display/ui/inventoryUtils.js @@ -26,6 +26,23 @@ 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 (!canInventoryItemApply(it)) return false; + return it.type === 'scroll' || it.type === 'tool' || it.type === 'utility'; +} + /** * @param {any} it * @returns {"none"|"apply"|"equip"|"use"|"set-spell"} @@ -33,8 +50,9 @@ export function isInventoryItemUsable(it) { export function getInventoryDefaultAction(it) { if (!it) return 'none'; if (isInventoryItemEquippable(it)) return 'equip'; + if (shouldInventoryItemPreferApply(it)) return 'apply'; if (isInventoryItemUsable(it)) return 'use'; - if (it.canApply && Number(it.applyTargetCount || 0) > 0) return 'apply'; + if (canInventoryItemApply(it)) 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..efa3941f 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,62 @@ export function initOverlays() { renderAlchemyBench(alchemy, _alchemyState); }); + // Enchanting bench overlay + let _enchantingState = { + benchId: 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 + 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, 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); + }); + // 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..ef9f079c 100644 --- a/src/display/ui/wiring/messages/economyMessages.js +++ b/src/display/ui/wiring/messages/economyMessages.js @@ -7,6 +7,34 @@ 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', + 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 = []; + 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; @@ -106,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; @@ -133,6 +167,29 @@ export function installEconomyMessages(ctx) { if (result === 'brew_failed') log('The brew collapses into sludge.', 'system'); }); + // === Enchanting events === + world.on('enchanting:open', ({ actor, ingredients, title }) => { + if (nameOfEntity(actor) !== 'You') return; + 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 }) => { + 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/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 new file mode 100644 index 00000000..12a4254e --- /dev/null +++ b/src/main/wiring/enchantingWiring.js @@ -0,0 +1,75 @@ +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, + 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, +}); + +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, title, subtitle }) => { + 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 : [], + title: String(title || ""), + subtitle: String(subtitle || ""), + }, + })); + } 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 station."); + 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/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 new file mode 100644 index 00000000..30697a8c --- /dev/null +++ b/src/rules/content/enchanting/benchGame.js @@ -0,0 +1,197 @@ +import { Inventory } from "../../components/Inventory.js"; +import { Position } from "../../components/Position.js"; +import { createItemById, isValidItemId } from "../../utils/itemFactory.js"; +import { addToInventory, consumeFromStack, getStackCount } from "../../utils/inventoryFacade.js"; +import { ENCHANTING_INGREDIENTS, getEnchantScrollDef, listEnchantRecipeDefs } from "./enchantCatalog.js"; + +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; + const recipes = listEnchantRecipeDefs(); + for (let i = 0; i < recipes.length; i++) { + if (recipes[i].key === key) return recipes[i]; + } + return null; +} + +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, options = {}) { + const ingredients = countEnchantingIngredients(world, actor); + const recipes = listEnchantRecipeDefs().map((recipe) => ({ + ...recipe, + metadata: { ...(recipe.metadata || { tier: 1, rarity: "magic" }) }, + canCraft: hasEnoughIngredients(ingredients, recipe.requirements || {}), + })); + 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, options = {}) { + 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, options); + world.emit?.("enchanting:result", { + actor, + targetId, + result: "unknown_recipe", + recipeKey: String(recipeKey || ""), + }); + 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 = {}; + 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, options); + 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, + 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); + 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..ae0d50b1 --- /dev/null +++ b/src/rules/content/enchanting/enchantCatalog.js @@ -0,0 +1,211 @@ +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 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", + recipeKey: "venomous_script", + 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.", + 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", + 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.", + 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", + 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.", + 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", + 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", + 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", + 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", + 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", + 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", + 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, + 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/content/interaction/interactPayloads.js b/src/rules/content/interaction/interactPayloads.js index 1f0dad76..c4ee1ed6 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"; @@ -116,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; @@ -884,6 +893,47 @@ 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); + }, + }, + + 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: { @@ -1862,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..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"], 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", "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/buildings/apothecary.js b/src/rules/data/buildings/apothecary.js index b2ddd80d..420ddbc6 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" }, @@ -82,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 b76439ee..65b69b07 100644 --- a/src/rules/data/itemCatalogMagic.js +++ b/src/rules/data/itemCatalogMagic.js @@ -31,6 +31,13 @@ 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 { + enchantDefSupportsSlot, + ENCHANT_SCROLL_DEFS, + getEnchantScrollDef, + normalizeEnchantSlot, +} from "../content/enchanting/enchantCatalog.js"; /** * Destroy any existing DerivedExpression entity owned by an active effect @@ -48,6 +55,123 @@ function cleanupPriorExprEntity(ctx, targetId, effectKey) { } } +function canEnchantScrollTarget(state) { + const targetInfo = state?.targetInfo; + if (!targetInfo || String(targetInfo.type || "") !== "equip") return false; + const slot = normalizeEnchantSlot(targetInfo.slot); + if (!slot || slot === "ammo") return false; + const scrollDef = getEnchantScrollDef(state?.toolIdentity || state?.toolId || ""); + const runtimeAffixId = scrollDef?.runtime?.affixId || scrollDef?.affixId; + if (!runtimeAffixId) return false; + return enchantDefSupportsSlot(scrollDef, slot) && affixSupportsSlot(runtimeAffixId, slot); +} + +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, 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)) { + 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 targetName = resolveApplyTargetName(ctx, state, "gear"); + const slot = normalizeEnchantSlot(info.slot); + if (!enchantDefSupportsSlot(scrollDef, slot) || !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", + 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, + magnitude, + proc, + duration, + metadata: { ...(metadata || {}) }, + message: `You bind ${resolvedLabel} into ${targetName}.${resolvedDetail ? ` ${resolvedDetail}` : ""}`, + }, + }); + return { applied: true, consumedTool: true, resultType: `${resolvedType}_gear_enchant` }; + }; +} + +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.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()}.`), + }, + }]), +); + export const MAGIC_ITEMS = { // Magic / Usable stone_touchstone: { @@ -2355,6 +2479,124 @@ export const MAGIC_ITEMS = { value: 8, description: "A hot, peppery root that keeps its heat long after harvest.", }, + reagent_spider_leg: { + id: "reagent_spider_leg", + catalogKind: "material", + name: "Spider Leg", + type: "ingredient", + slot: "bag", + 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: "uncommon", + weight: 0.2, + value: 12, + description: "A sealed venom sac prized by poisoners and enchanters alike.", + }, + reagent_resin: { + id: "reagent_resin", + catalogKind: "material", + name: "Binding Resin", + type: "ingredient", + slot: "bag", + 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: "uncommon", + weight: 0.15, + value: 13, + description: "Cold spectral residue that clings to glass and cloth.", + }, + reagent_rune_fragment: { + id: "reagent_rune_fragment", + catalogKind: "material", + name: "Rune Fragment", + type: "ingredient", + slot: "bag", + material: "stone", + rarity: 2, + rarityName: "uncommon", + weight: 0.1, + 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..67d53af8 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: "reagent_cursed_thread" }, + { type: "item", weight: 6, itemId: "reagent_frost_core" }, + ], + }, + "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..416a7884 100644 --- a/src/rules/data/shopStock.js +++ b/src/rules/data/shopStock.js @@ -157,6 +157,12 @@ 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 }, ]); 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 fe857a6e..232cad11 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, @@ -2444,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 new file mode 100644 index 00000000..78d8b6f2 --- /dev/null +++ b/tests/enchantingBench.test.mjs @@ -0,0 +1,196 @@ +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 { 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"; +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: 50, 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_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 = []; + 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(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); + 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 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"); + 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 = []; + 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")); + + 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, 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"); +}); + +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}`); + } + + 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); + 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 new file mode 100644 index 00000000..205b166e --- /dev/null +++ b/tests/enchantingContent.test.mjs @@ -0,0 +1,38 @@ +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", () => { + 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); +}); + +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}`); + } +}); 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..dcb41d3c --- /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); +});