fix: apply Crafting Efficiency cost reduction to all fabrication paths
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s

- Add getCraftingCostReduction() and applyCostReduction() helpers in crafting-fabricator.ts
- Apply cost reduction in deductMaterials() and checkFabricatorCosts()
- Apply cost reduction in startFabricatorCrafting() and cancelEquipmentCrafting() pipeline
- Update canCraftRecipe() in fabricator-recipes.ts to accept costReduction param
- Update FabricatorSubTab and MaterialRecipeCard UIs to display discounted costs
- Spec formula: actualCost = ceil(baseCost × (1 - craftingCostReduction / 100))

Fixes #316
This commit is contained in:
2026-06-07 23:15:55 +02:00
parent a11ea065eb
commit 0e1e506213
7 changed files with 77 additions and 16 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Circular Dependencies # Circular Dependencies
Generated: 2026-06-07T16:09:18.340Z Generated: 2026-06-07T21:06:17.789Z
No circular dependencies found. ✅ No circular dependencies found. ✅
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_meta": {
"generated": "2026-06-07T16:09:16.437Z", "generated": "2026-06-07T21:06:15.829Z",
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry." "usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
}, },
@@ -15,6 +15,7 @@ import { LOOT_DROPS, LOOT_RARITY_COLORS } from '@/lib/game/data/loot-drops';
import { useCraftingStore, useManaStore } from '@/lib/game/stores'; import { useCraftingStore, useManaStore } from '@/lib/game/stores';
import type { FabricatorRecipe } from '@/lib/game/data/fabricator-recipes'; import type { FabricatorRecipe } from '@/lib/game/data/fabricator-recipes';
import { DebugName } from '@/components/game/debug/debug-context'; import { DebugName } from '@/components/game/debug/debug-context';
import { getCraftingCostReduction, applyCostReduction } from '@/lib/game/crafting-fabricator';
const BRANCH_RECIPE_IDS = new Set([ const BRANCH_RECIPE_IDS = new Set([
'oakStaff', 'arcanistStaff', 'battlestaff', 'arcanistCirclet', 'arcanistRobe', 'oakStaff', 'arcanistStaff', 'battlestaff', 'arcanistCirclet', 'arcanistRobe',
@@ -40,6 +41,7 @@ function RecipeCard({
elementalMana, elementalMana,
onCraft, onCraft,
isCrafting, isCrafting,
costReduction,
}: { }: {
recipe: FabricatorRecipe; recipe: FabricatorRecipe;
materials: Record<string, number>; materials: Record<string, number>;
@@ -47,6 +49,7 @@ function RecipeCard({
elementalMana: Record<string, { current: number; max: number; unlocked: boolean }>; elementalMana: Record<string, { current: number; max: number; unlocked: boolean }>;
onCraft: (recipe: FabricatorRecipe) => void; onCraft: (recipe: FabricatorRecipe) => void;
isCrafting: boolean; isCrafting: boolean;
costReduction: number;
}) { }) {
const pool = recipe.manaType === 'raw' const pool = recipe.manaType === 'raw'
? rawMana ? rawMana
@@ -56,6 +59,7 @@ function RecipeCard({
materials, materials,
pool, pool,
recipe.manaType, recipe.manaType,
costReduction,
); );
const rarityStyle = LOOT_RARITY_COLORS[recipe.rarity]; const rarityStyle = LOOT_RARITY_COLORS[recipe.rarity];
@@ -82,17 +86,26 @@ function RecipeCard({
<Separator className="bg-gray-700 my-2" /> <Separator className="bg-gray-700 my-2" />
<div className="text-xs space-y-1"> <div className="text-xs space-y-1">
<div className="text-gray-500">Materials:</div> <div className="text-gray-500 flex justify-between">
{Object.entries(recipe.materials).map(([matId, amount]) => { <span>Materials:</span>
{costReduction > 0 && (
<span className="text-cyan-400">-{costReduction}% cost</span>
)}
</div>
{Object.entries(recipe.materials).map(([matId, rawAmount]) => {
const reducedAmount = applyCostReduction(rawAmount, costReduction);
const available = materials[matId] || 0; const available = materials[matId] || 0;
const hasEnough = available >= reducedAmount;
const matDrop = LOOT_DROPS[matId]; const matDrop = LOOT_DROPS[matId];
const hasEnough = available >= amount;
return ( return (
<div key={matId} className="flex justify-between"> <div key={matId} className="flex justify-between">
<span>{matDrop?.name ?? matId}</span> <span>{matDrop?.name ?? matId}</span>
<span className={hasEnough ? 'text-green-400' : 'text-red-400'}> <span className={hasEnough ? 'text-green-400' : 'text-red-400'}>
{available} / {amount} {available} / {reducedAmount}
{costReduction > 0 && rawAmount !== reducedAmount && (
<span className="text-gray-500 ml-1">(was {rawAmount})</span>
)}
</span> </span>
</div> </div>
); );
@@ -156,6 +169,7 @@ export function FabricatorSubTab() {
}, [selectedManaType, branchFilter, unlockedRecipes]); }, [selectedManaType, branchFilter, unlockedRecipes]);
const isCrafting = equipmentCraftingProgress !== null; const isCrafting = equipmentCraftingProgress !== null;
const costReduction = getCraftingCostReduction();
const materialRecipes = useMemo(() => MATERIAL_RECIPES, []); const materialRecipes = useMemo(() => MATERIAL_RECIPES, []);
@@ -300,6 +314,7 @@ export function FabricatorSubTab() {
elementalMana={elements} elementalMana={elements}
onCraft={handleCraft} onCraft={handleCraft}
isCrafting={isCrafting} isCrafting={isCrafting}
costReduction={costReduction}
/> />
)) ))
) )
@@ -318,6 +333,7 @@ export function FabricatorSubTab() {
rawMana={rawMana} rawMana={rawMana}
elementalMana={elements} elementalMana={elements}
onCraft={handleCraft} onCraft={handleCraft}
costReduction={costReduction}
/> />
)) ))
) )
@@ -8,6 +8,7 @@ import { canCraftRecipe } from '@/lib/game/data/fabricator-recipes';
import { MANA_TYPE_LABELS } from '@/lib/game/data/fabricator-recipe-types'; import { MANA_TYPE_LABELS } from '@/lib/game/data/fabricator-recipe-types';
import type { FabricatorRecipe } from '@/lib/game/data/fabricator-recipes'; import type { FabricatorRecipe } from '@/lib/game/data/fabricator-recipes';
import { DebugName } from '@/components/game/debug/debug-context'; import { DebugName } from '@/components/game/debug/debug-context';
import { getCraftingCostReduction, applyCostReduction } from '@/lib/game/crafting-fabricator';
interface MaterialRecipeCardProps { interface MaterialRecipeCardProps {
recipe: FabricatorRecipe; recipe: FabricatorRecipe;
@@ -15,6 +16,7 @@ interface MaterialRecipeCardProps {
rawMana: number; rawMana: number;
elementalMana: Record<string, { current: number; max: number; unlocked: boolean }>; elementalMana: Record<string, { current: number; max: number; unlocked: boolean }>;
onCraft: (recipe: FabricatorRecipe) => void; onCraft: (recipe: FabricatorRecipe) => void;
costReduction?: number;
} }
export function MaterialRecipeCard({ export function MaterialRecipeCard({
@@ -23,11 +25,12 @@ export function MaterialRecipeCard({
rawMana, rawMana,
elementalMana, elementalMana,
onCraft, onCraft,
costReduction = 0,
}: MaterialRecipeCardProps) { }: MaterialRecipeCardProps) {
const pool = recipe.manaType === 'raw' const pool = recipe.manaType === 'raw'
? rawMana ? rawMana
: (elementalMana[recipe.manaType]?.current ?? 0); : (elementalMana[recipe.manaType]?.current ?? 0);
const { canCraft } = canCraftRecipe(recipe, materials, pool, recipe.manaType); const { canCraft } = canCraftRecipe(recipe, materials, pool, recipe.manaType, costReduction);
const resultDrop = recipe.resultMaterial ? LOOT_DROPS[recipe.resultMaterial] : null; const resultDrop = recipe.resultMaterial ? LOOT_DROPS[recipe.resultMaterial] : null;
const resultRarity = resultDrop ? LOOT_RARITY_COLORS[resultDrop.rarity] : null; const resultRarity = resultDrop ? LOOT_RARITY_COLORS[resultDrop.rarity] : null;
@@ -57,16 +60,20 @@ export function MaterialRecipeCard({
{Object.keys(recipe.materials).length > 0 && ( {Object.keys(recipe.materials).length > 0 && (
<> <>
<div className="text-gray-500">Input Materials:</div> <div className="text-gray-500">Input Materials:</div>
{Object.entries(recipe.materials).map(([matId, amount]) => { {Object.entries(recipe.materials).map(([matId, rawAmount]) => {
const reducedAmount = applyCostReduction(rawAmount, costReduction);
const available = materials[matId] || 0; const available = materials[matId] || 0;
const matDrop = LOOT_DROPS[matId]; const matDrop = LOOT_DROPS[matId];
const hasEnough = available >= amount; const hasEnough = available >= reducedAmount;
return ( return (
<div key={matId} className="flex justify-between"> <div key={matId} className="flex justify-between">
<span>{matDrop?.name ?? matId}</span> <span>{matDrop?.name ?? matId}</span>
<span className={hasEnough ? 'text-green-400' : 'text-red-400'}> <span className={hasEnough ? 'text-green-400' : 'text-red-400'}>
{available} / {amount} {available} / {reducedAmount}
{costReduction > 0 && rawAmount !== reducedAmount && (
<span className="text-gray-500 ml-1">(was {rawAmount})</span>
)}
</span> </span>
</div> </div>
); );
+30 -2
View File
@@ -6,6 +6,7 @@ import type { ElementState } from './types';
import type { FabricatorRecipe } from './data/fabricator-recipes'; import type { FabricatorRecipe } from './data/fabricator-recipes';
import { FABRICATOR_RECIPES } from './data/fabricator-recipes'; import { FABRICATOR_RECIPES } from './data/fabricator-recipes';
import { useManaStore } from './stores/manaStore'; import { useManaStore } from './stores/manaStore';
import { computeDisciplineEffects } from './effects/discipline-effects';
// ─── Lookup ─────────────────────────────────────────────────────────────────── // ─── Lookup ───────────────────────────────────────────────────────────────────
@@ -13,6 +14,27 @@ export function getFabricatorRecipe(recipeId: string): FabricatorRecipe | undefi
return FABRICATOR_RECIPES.find(r => r.id === recipeId); return FABRICATOR_RECIPES.find(r => r.id === recipeId);
} }
// ─── Crafting Cost Reduction ──────────────────────────────────────────────────
/**
* Returns the current crafting cost reduction percentage from discipline effects.
* Spec: actualCost = baseCost × (1 - craftingCostReduction / 100), capped at 75%.
*/
export function getCraftingCostReduction(): number {
const disciplineEffects = computeDisciplineEffects();
const raw = disciplineEffects.bonuses.craftingCostReduction || 0;
return Math.min(75, raw);
}
/**
* Apply cost reduction to a material amount, rounding up to ensure
* the player never pays more than the displayed reduced cost.
*/
export function applyCostReduction(baseAmount: number, costReduction: number): number {
if (costReduction <= 0) return baseAmount;
return Math.max(1, Math.ceil(baseAmount * (1 - costReduction / 100)));
}
// ─── Cost Check ─────────────────────────────────────────────────────────────── // ─── Cost Check ───────────────────────────────────────────────────────────────
export interface FabricatorCostCheck { export interface FabricatorCostCheck {
@@ -26,11 +48,14 @@ export function checkFabricatorCosts(
materials: Record<string, number>, materials: Record<string, number>,
rawMana: number, rawMana: number,
elements: Record<string, ElementState>, elements: Record<string, ElementState>,
costReduction?: number,
): FabricatorCostCheck { ): FabricatorCostCheck {
const reduction = costReduction ?? getCraftingCostReduction();
const missingMaterials: Record<string, number> = {}; const missingMaterials: Record<string, number> = {};
let canCraft = true; let canCraft = true;
for (const [matId, required] of Object.entries(recipe.materials)) { for (const [matId, rawRequired] of Object.entries(recipe.materials)) {
const required = applyCostReduction(rawRequired, reduction);
const available = materials[matId] || 0; const available = materials[matId] || 0;
if (available < required) { if (available < required) {
missingMaterials[matId] = required - available; missingMaterials[matId] = required - available;
@@ -116,9 +141,12 @@ export function refundFabricatorMana(
export function deductMaterials( export function deductMaterials(
recipe: FabricatorRecipe, recipe: FabricatorRecipe,
materials: Record<string, number>, materials: Record<string, number>,
costReduction?: number,
): Record<string, number> { ): Record<string, number> {
const reduction = costReduction ?? getCraftingCostReduction();
const newMaterials = { ...materials }; const newMaterials = { ...materials };
for (const [matId, amount] of Object.entries(recipe.materials)) { for (const [matId, rawAmount] of Object.entries(recipe.materials)) {
const amount = applyCostReduction(rawAmount, reduction);
newMaterials[matId] = (newMaterials[matId] || 0) - amount; newMaterials[matId] = (newMaterials[matId] || 0) - amount;
if (newMaterials[matId] <= 0) delete newMaterials[matId]; if (newMaterials[matId] <= 0) delete newMaterials[matId];
} }
+6 -1
View File
@@ -236,11 +236,16 @@ export function canCraftRecipe(
materials: Record<string, number>, materials: Record<string, number>,
manaAmount: number, manaAmount: number,
manaType?: string, manaType?: string,
costReduction?: number,
): { canCraft: boolean; missingMaterials: Record<string, number>; missingMana: number } { ): { canCraft: boolean; missingMaterials: Record<string, number>; missingMana: number } {
const reduction = costReduction ?? 0;
const missingMaterials: Record<string, number> = {}; const missingMaterials: Record<string, number> = {};
let canCraft = true; let canCraft = true;
for (const [matId, required] of Object.entries(recipe.materials)) { for (const [matId, rawRequired] of Object.entries(recipe.materials)) {
const required = reduction > 0
? Math.max(1, Math.ceil(rawRequired * (1 - reduction / 100)))
: rawRequired;
const available = materials[matId] || 0; const available = materials[matId] || 0;
if (available < required) { if (available < required) {
missingMaterials[matId] = required - available; missingMaterials[matId] = required - available;
@@ -10,6 +10,8 @@ import {
deductMaterials, deductMaterials,
makeFabricatorProgress, makeFabricatorProgress,
refundFabricatorMana, refundFabricatorMana,
getCraftingCostReduction,
applyCostReduction,
} from '../../crafting-fabricator'; } from '../../crafting-fabricator';
import { useManaStore } from '../manaStore'; import { useManaStore } from '../manaStore';
import { useCombatStore } from '../combatStore'; import { useCombatStore } from '../combatStore';
@@ -72,11 +74,13 @@ export function cancelEquipmentCrafting(get: GetFn, set: SetFn): void {
const refunded = refundFabricatorMana(recipe, manaRefund, rawMana, elements); const refunded = refundFabricatorMana(recipe, manaRefund, rawMana, elements);
useManaStore.setState({ rawMana: refunded.rawMana, elements: refunded.elements }); useManaStore.setState({ rawMana: refunded.rawMana, elements: refunded.elements });
// Refund materials // Refund materials — use reduced amounts (what player actually paid)
const reduction = getCraftingCostReduction();
const currentMaterials = get().lootInventory.materials; const currentMaterials = get().lootInventory.materials;
const refundedMaterials = { ...currentMaterials }; const refundedMaterials = { ...currentMaterials };
for (const [matId, amount] of Object.entries(recipe.materials)) { for (const [matId, rawAmount] of Object.entries(recipe.materials)) {
const refundAmount = Math.floor(amount * remainingFraction); const reducedAmount = applyCostReduction(rawAmount, reduction);
const refundAmount = Math.floor(reducedAmount * remainingFraction);
if (refundAmount > 0) { if (refundAmount > 0) {
refundedMaterials[matId] = (refundedMaterials[matId] || 0) + refundAmount; refundedMaterials[matId] = (refundedMaterials[matId] || 0) + refundAmount;
} }
@@ -132,6 +136,7 @@ export function startFabricatorCrafting(recipeId: string, get: GetFn, set: SetFn
const deducted = deductFabricatorMana(recipe, rawMana, elements); const deducted = deductFabricatorMana(recipe, rawMana, elements);
if (!deducted) return false; if (!deducted) return false;
// deductMaterials already applies cost reduction internally via getCraftingCostReduction()
const newMaterials = deductMaterials(recipe, state.lootInventory.materials); const newMaterials = deductMaterials(recipe, state.lootInventory.materials);
const progress = makeFabricatorProgress(recipeId, recipe.equipmentTypeId, recipe.craftTime, recipe.manaCost); const progress = makeFabricatorProgress(recipeId, recipe.equipmentTypeId, recipe.craftTime, recipe.manaCost);