fix: apply Crafting Efficiency cost reduction to all fabrication paths
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
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:
@@ -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,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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user