feat: add fabricator disciplines for recipe unlocks
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
- Add unlocksRecipes field to DisciplinePerk type - Add study-fabricator-recipes discipline (earth/metal/sand/crystal recipes) - Add study-wizard-branch discipline (wizard equipment recipes) - Add study-physical-branch discipline (physical combat equipment recipes) - Collect unlocksRecipes during discipline tick processing - Pass recipe unlocks to crafting store via gameStore - Add unlockRecipes action to craftingStore with persistence - Filter fabricator recipes by unlock status in FabricatorSubTab UI
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# Circular Dependencies
|
# Circular Dependencies
|
||||||
Generated: 2026-05-29T13:10:03.724Z
|
Generated: 2026-05-29T13:23:45.664Z
|
||||||
|
|
||||||
No circular dependencies found. ✅
|
No circular dependencies found. ✅
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"generated": "2026-05-29T13:10:02.096Z",
|
"generated": "2026-05-29T13:23:43.981Z",
|
||||||
"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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export function FabricatorSubTab() {
|
|||||||
const [branchFilter, setBranchFilter] = useState<BranchFilter>('all');
|
const [branchFilter, setBranchFilter] = useState<BranchFilter>('all');
|
||||||
|
|
||||||
const lootInventory = useCraftingStore((s) => s.lootInventory);
|
const lootInventory = useCraftingStore((s) => s.lootInventory);
|
||||||
|
const unlockedRecipes = useCraftingStore((s) => s.unlockedRecipes);
|
||||||
const equipmentCraftingProgress = useCraftingStore((s) => s.equipmentCraftingProgress);
|
const equipmentCraftingProgress = useCraftingStore((s) => s.equipmentCraftingProgress);
|
||||||
const rawMana = useManaStore((s) => s.rawMana);
|
const rawMana = useManaStore((s) => s.rawMana);
|
||||||
const elements = useManaStore((s) => s.elements);
|
const elements = useManaStore((s) => s.elements);
|
||||||
@@ -156,8 +157,8 @@ export function FabricatorSubTab() {
|
|||||||
} else if (branchFilter === 'physical') {
|
} else if (branchFilter === 'physical') {
|
||||||
recipes = recipes.filter(r => isPhysicalBranch(r));
|
recipes = recipes.filter(r => isPhysicalBranch(r));
|
||||||
}
|
}
|
||||||
return recipes.filter(r => r.manaType === selectedManaType);
|
return recipes.filter(r => r.manaType === selectedManaType && unlockedRecipes.includes(r.id));
|
||||||
}, [selectedManaType, branchFilter]);
|
}, [selectedManaType, branchFilter, unlockedRecipes]);
|
||||||
|
|
||||||
const isCrafting = equipmentCraftingProgress !== null;
|
const isCrafting = equipmentCraftingProgress !== null;
|
||||||
|
|
||||||
|
|||||||
@@ -57,4 +57,176 @@ export const fabricatorDisciplines: DisciplineDefinition[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'study-fabricator-recipes',
|
||||||
|
name: 'Study Fabricator Recipes',
|
||||||
|
attunement: DisciplinesAttunementType.FABRICATOR,
|
||||||
|
manaType: 'earth',
|
||||||
|
baseCost: 10,
|
||||||
|
description: 'Learn to craft elemental equipment at the fabricator.',
|
||||||
|
statBonus: { stat: 'enchantPower', baseValue: 3, label: 'Enchantment Power' },
|
||||||
|
difficultyFactor: 100,
|
||||||
|
scalingFactor: 80,
|
||||||
|
drainBase: 2,
|
||||||
|
perks: [
|
||||||
|
{
|
||||||
|
id: 'fabricator-earth-recipes',
|
||||||
|
type: 'once',
|
||||||
|
threshold: 50,
|
||||||
|
value: 0,
|
||||||
|
description: 'Unlock Earth fabricator recipes',
|
||||||
|
unlocksRecipes: ['earthHelm', 'earthChest', 'earthBoots'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fabricator-metal-recipes',
|
||||||
|
type: 'once',
|
||||||
|
threshold: 100,
|
||||||
|
value: 0,
|
||||||
|
description: 'Unlock Metal fabricator recipes',
|
||||||
|
unlocksRecipes: ['metalBlade', 'metalShield', 'metalGloves'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fabricator-sand-recipes',
|
||||||
|
type: 'once',
|
||||||
|
threshold: 150,
|
||||||
|
value: 0,
|
||||||
|
description: 'Unlock Sand fabricator recipes',
|
||||||
|
unlocksRecipes: ['sandBoots', 'sandGloves', 'sandVest'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fabricator-crystal-recipes',
|
||||||
|
type: 'once',
|
||||||
|
threshold: 200,
|
||||||
|
value: 0,
|
||||||
|
description: 'Unlock Crystal fabricator recipes',
|
||||||
|
unlocksRecipes: ['crystalWand', 'crystalRing', 'crystalAmulet'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'study-wizard-branch',
|
||||||
|
name: 'Study Wizard Equipment',
|
||||||
|
attunement: DisciplinesAttunementType.FABRICATOR,
|
||||||
|
manaType: 'earth',
|
||||||
|
baseCost: 15,
|
||||||
|
description: 'Learn to craft advanced wizard equipment.',
|
||||||
|
statBonus: { stat: 'enchantPower', baseValue: 5, label: 'Enchantment Power' },
|
||||||
|
difficultyFactor: 150,
|
||||||
|
scalingFactor: 100,
|
||||||
|
drainBase: 3,
|
||||||
|
requires: ['study-fabricator-recipes'],
|
||||||
|
perks: [
|
||||||
|
{
|
||||||
|
id: 'wizard-oak-staff',
|
||||||
|
type: 'once',
|
||||||
|
threshold: 50,
|
||||||
|
value: 0,
|
||||||
|
description: 'Unlock Oak Staff recipe',
|
||||||
|
unlocksRecipes: ['oakStaff'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wizard-arcanist-staff',
|
||||||
|
type: 'once',
|
||||||
|
threshold: 100,
|
||||||
|
value: 0,
|
||||||
|
description: 'Unlock Arcanist Staff recipe',
|
||||||
|
unlocksRecipes: ['arcanistStaff'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wizard-battlestaff',
|
||||||
|
type: 'once',
|
||||||
|
threshold: 150,
|
||||||
|
value: 0,
|
||||||
|
description: 'Unlock Battlestaff recipe',
|
||||||
|
unlocksRecipes: ['battlestaff'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wizard-arcanist-set',
|
||||||
|
type: 'once',
|
||||||
|
threshold: 200,
|
||||||
|
value: 0,
|
||||||
|
description: 'Unlock Arcanist Circlet and Robe recipes',
|
||||||
|
unlocksRecipes: ['arcanistCirclet', 'arcanistRobe'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wizard-void-catalyst',
|
||||||
|
type: 'once',
|
||||||
|
threshold: 250,
|
||||||
|
value: 0,
|
||||||
|
description: 'Unlock Void Catalyst recipe',
|
||||||
|
unlocksRecipes: ['voidCatalyst'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wizard-arcanist-pendant',
|
||||||
|
type: 'once',
|
||||||
|
threshold: 300,
|
||||||
|
value: 0,
|
||||||
|
description: 'Unlock Arcanist Pendant recipe',
|
||||||
|
unlocksRecipes: ['arcanistPendant'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'study-physical-branch',
|
||||||
|
name: 'Study Physical Equipment',
|
||||||
|
attunement: DisciplinesAttunementType.FABRICATOR,
|
||||||
|
manaType: 'earth',
|
||||||
|
baseCost: 15,
|
||||||
|
description: 'Learn to craft advanced physical combat equipment.',
|
||||||
|
statBonus: { stat: 'enchantPower', baseValue: 5, label: 'Enchantment Power' },
|
||||||
|
difficultyFactor: 150,
|
||||||
|
scalingFactor: 100,
|
||||||
|
drainBase: 3,
|
||||||
|
requires: ['study-fabricator-recipes'],
|
||||||
|
perks: [
|
||||||
|
{
|
||||||
|
id: 'physical-crystal-blade',
|
||||||
|
type: 'once',
|
||||||
|
threshold: 50,
|
||||||
|
value: 0,
|
||||||
|
description: 'Unlock Crystal Blade recipe',
|
||||||
|
unlocksRecipes: ['crystalBlade'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'physical-arcanist-blade',
|
||||||
|
type: 'once',
|
||||||
|
threshold: 100,
|
||||||
|
value: 0,
|
||||||
|
description: 'Unlock Arcanist Blade recipe',
|
||||||
|
unlocksRecipes: ['arcanistBlade'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'physical-void-blade',
|
||||||
|
type: 'once',
|
||||||
|
threshold: 150,
|
||||||
|
value: 0,
|
||||||
|
description: 'Unlock Void Blade recipe',
|
||||||
|
unlocksRecipes: ['voidBlade'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'physical-battle-set',
|
||||||
|
type: 'once',
|
||||||
|
threshold: 200,
|
||||||
|
value: 0,
|
||||||
|
description: 'Unlock Battle Helm and Robe recipes',
|
||||||
|
unlocksRecipes: ['battleHelm', 'battleRobe'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'physical-battle-boots',
|
||||||
|
type: 'once',
|
||||||
|
threshold: 250,
|
||||||
|
value: 0,
|
||||||
|
description: 'Unlock Battle Boots recipe',
|
||||||
|
unlocksRecipes: ['battleBoots'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'physical-combat-gauntlets',
|
||||||
|
type: 'once',
|
||||||
|
threshold: 300,
|
||||||
|
value: 0,
|
||||||
|
description: 'Unlock Combat Gauntlets recipe',
|
||||||
|
unlocksRecipes: ['combatGauntlets'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
@@ -19,6 +19,7 @@ export function createDefaultCraftingState(): CraftingState {
|
|||||||
equipmentCraftingProgress: null,
|
equipmentCraftingProgress: null,
|
||||||
enchantmentDesigns: [],
|
enchantmentDesigns: [],
|
||||||
unlockedEffects: [],
|
unlockedEffects: [],
|
||||||
|
unlockedRecipes: [],
|
||||||
equippedInstances: initial.equippedInstances,
|
equippedInstances: initial.equippedInstances,
|
||||||
equipmentInstances: initial.instances,
|
equipmentInstances: initial.instances,
|
||||||
lootInventory: {
|
lootInventory: {
|
||||||
|
|||||||
@@ -344,6 +344,21 @@ export const useCraftingStore = create<CraftingStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
unlockRecipes: (recipeIds: string[]) => {
|
||||||
|
set((state) => {
|
||||||
|
const existing = new Set(state.unlockedRecipes);
|
||||||
|
let changed = false;
|
||||||
|
for (const id of recipeIds) {
|
||||||
|
if (!existing.has(id)) {
|
||||||
|
existing.add(id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) return state;
|
||||||
|
return { unlockedRecipes: Array.from(existing) };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
processEquipmentCraftingTick: (): { completed: boolean; logMessage?: string } => {
|
processEquipmentCraftingTick: (): { completed: boolean; logMessage?: string } => {
|
||||||
const state = get();
|
const state = get();
|
||||||
return processEquipmentCraftingTick(state, set as unknown as (partial: Partial<CraftingState>) => void);
|
return processEquipmentCraftingTick(state, set as unknown as (partial: Partial<CraftingState>) => void);
|
||||||
@@ -366,6 +381,7 @@ export const useCraftingStore = create<CraftingStore>()(
|
|||||||
equipmentCraftingProgress: state.equipmentCraftingProgress,
|
equipmentCraftingProgress: state.equipmentCraftingProgress,
|
||||||
enchantmentDesigns: state.enchantmentDesigns,
|
enchantmentDesigns: state.enchantmentDesigns,
|
||||||
unlockedEffects: state.unlockedEffects,
|
unlockedEffects: state.unlockedEffects,
|
||||||
|
unlockedRecipes: state.unlockedRecipes,
|
||||||
equipmentInstances: state.equipmentInstances,
|
equipmentInstances: state.equipmentInstances,
|
||||||
equippedInstances: state.equippedInstances,
|
equippedInstances: state.equippedInstances,
|
||||||
lootInventory: state.lootInventory,
|
lootInventory: state.lootInventory,
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface CraftingState {
|
|||||||
equipmentCraftingProgress: EquipmentCraftingProgress | null;
|
equipmentCraftingProgress: EquipmentCraftingProgress | null;
|
||||||
enchantmentDesigns: EnchantmentDesign[];
|
enchantmentDesigns: EnchantmentDesign[];
|
||||||
unlockedEffects: string[];
|
unlockedEffects: string[];
|
||||||
|
unlockedRecipes: string[];
|
||||||
equipmentInstances: Record<string, EquipmentInstance>;
|
equipmentInstances: Record<string, EquipmentInstance>;
|
||||||
equippedInstances: Record<string, string | null>;
|
equippedInstances: Record<string, string | null>;
|
||||||
lootInventory: {
|
lootInventory: {
|
||||||
@@ -72,6 +73,7 @@ export interface CraftingActions {
|
|||||||
resetEnchantmentSelection: () => void;
|
resetEnchantmentSelection: () => void;
|
||||||
clearLastError: () => void;
|
clearLastError: () => void;
|
||||||
unlockEffects: (effectIds: string[]) => void;
|
unlockEffects: (effectIds: string[]) => void;
|
||||||
|
unlockRecipes: (recipeIds: string[]) => void;
|
||||||
processEquipmentCraftingTick: () => { completed: boolean; logMessage?: string };
|
processEquipmentCraftingTick: () => { completed: boolean; logMessage?: string };
|
||||||
resetCrafting: () => void;
|
resetCrafting: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export interface DisciplineStoreActions {
|
|||||||
rawMana: number;
|
rawMana: number;
|
||||||
elements: Record<string, ElementState>;
|
elements: Record<string, ElementState>;
|
||||||
unlockedEffects: string[];
|
unlockedEffects: string[];
|
||||||
|
unlockedRecipes: string[];
|
||||||
};
|
};
|
||||||
setPracticingCallbacks(callbacks: { onStartPracticing: () => void; onStopPracticing: () => void }): void;
|
setPracticingCallbacks(callbacks: { onStartPracticing: () => void; onStopPracticing: () => void }): void;
|
||||||
resetDisciplines: () => void;
|
resetDisciplines: () => void;
|
||||||
@@ -158,6 +159,7 @@ export const useDisciplineStore = create<DisciplineStore>()(
|
|||||||
let newXP = s.totalXP;
|
let newXP = s.totalXP;
|
||||||
const newDisciplines = { ...s.disciplines };
|
const newDisciplines = { ...s.disciplines };
|
||||||
const newUnlockedEffects: string[] = [];
|
const newUnlockedEffects: string[] = [];
|
||||||
|
const newUnlockedRecipes: string[] = [];
|
||||||
const newProcessedPerks = [...s.processedPerks];
|
const newProcessedPerks = [...s.processedPerks];
|
||||||
|
|
||||||
const drainedIds: string[] = [];
|
const drainedIds: string[] = [];
|
||||||
@@ -221,10 +223,15 @@ export const useDisciplineStore = create<DisciplineStore>()(
|
|||||||
const newPerks = getUnlockedPerks(def, disc.xp + 1);
|
const newPerks = getUnlockedPerks(def, disc.xp + 1);
|
||||||
const oldPerkIds = new Set(oldPerks.map(p => p.id));
|
const oldPerkIds = new Set(oldPerks.map(p => p.id));
|
||||||
for (const perk of newPerks) {
|
for (const perk of newPerks) {
|
||||||
if (!oldPerkIds.has(perk.id) && perk.unlocksEffects) {
|
if (!oldPerkIds.has(perk.id)) {
|
||||||
const perkKey = `${id}:${perk.id}`;
|
const perkKey = `${id}:${perk.id}`;
|
||||||
if (!newProcessedPerks.includes(perkKey)) {
|
if (!newProcessedPerks.includes(perkKey)) {
|
||||||
newUnlockedEffects.push(...perk.unlocksEffects);
|
if (perk.unlocksEffects) {
|
||||||
|
newUnlockedEffects.push(...perk.unlocksEffects);
|
||||||
|
}
|
||||||
|
if (perk.unlocksRecipes) {
|
||||||
|
newUnlockedRecipes.push(...perk.unlocksRecipes);
|
||||||
|
}
|
||||||
newProcessedPerks.push(perkKey);
|
newProcessedPerks.push(perkKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,7 +258,7 @@ export const useDisciplineStore = create<DisciplineStore>()(
|
|||||||
processedPerks: newProcessedPerks,
|
processedPerks: newProcessedPerks,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { rawMana, elements, unlockedEffects: newUnlockedEffects };
|
return { rawMana, elements, unlockedEffects: newUnlockedEffects, unlockedRecipes: newUnlockedRecipes };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{ storage: createSafeStorage(), name: 'mana-loop-discipline-store', version: 1, partialize: (state) => ({ disciplines: state.disciplines, activeIds: state.activeIds, concurrentLimit: state.concurrentLimit, totalXP: state.totalXP, processedPerks: state.processedPerks }) }
|
{ storage: createSafeStorage(), name: 'mana-loop-discipline-store', version: 1, partialize: (state) => ({ disciplines: state.disciplines, activeIds: state.activeIds, concurrentLimit: state.concurrentLimit, totalXP: state.totalXP, processedPerks: state.processedPerks }) }
|
||||||
|
|||||||
@@ -277,6 +277,14 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unlock fabricator recipes from newly unlocked discipline perks
|
||||||
|
if (disciplineResult.unlockedRecipes.length > 0) {
|
||||||
|
useCraftingStore.getState().unlockRecipes(disciplineResult.unlockedRecipes);
|
||||||
|
for (const recipeId of disciplineResult.unlockedRecipes) {
|
||||||
|
addLog(`🔨 Fabricator recipe unlocked: ${recipeId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply per-element capacity bonuses from disciplines and equipment
|
// Apply per-element capacity bonuses from disciplines and equipment
|
||||||
const perElementCapBonuses = mergePerElementCapBonuses(
|
const perElementCapBonuses = mergePerElementCapBonuses(
|
||||||
disciplineEffects.bonuses,
|
disciplineEffects.bonuses,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface DisciplinePerk {
|
|||||||
value: number;
|
value: number;
|
||||||
description: string;
|
description: string;
|
||||||
unlocksEffects?: string[];
|
unlocksEffects?: string[];
|
||||||
|
unlocksRecipes?: string[];
|
||||||
bonus?: PerkBonus;
|
bonus?: PerkBonus;
|
||||||
/**
|
/**
|
||||||
* For capped perks: maximum number of tiers. If unset, capped perks
|
* For capped perks: maximum number of tiers. If unset, capped perks
|
||||||
|
|||||||
Reference in New Issue
Block a user