refactor: cleanup codebase — remove hydration guards, extract constants, fix bugs
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
|
||||
import type { EquipmentInstance, AppliedEnchantment, EnchantmentDesign, ApplicationProgress } from './types';
|
||||
import { calculateApplicationTime, calculateApplicationManaPerHour } from './crafting-utils';
|
||||
import { HOURS_PER_TICK } from './constants';
|
||||
import { hasSpecial, SPECIAL_EFFECTS } from './effects/special-effects';
|
||||
import type { ComputedEffects } from './effects/upgrade-effects.types';
|
||||
import type { AttunementState } from './types';
|
||||
@@ -11,32 +12,16 @@ import { ENCHANTMENT_EFFECTS } from './data/enchantment-effects';
|
||||
|
||||
// ─── Application Validation ─────────────────────────────────────────────────
|
||||
|
||||
// Check if enchantment application can start
|
||||
export function canApplyEnchantment(
|
||||
instance: EquipmentInstance | undefined,
|
||||
design: EnchantmentDesign | undefined,
|
||||
currentAction: string
|
||||
): { canApply: boolean; reason?: string } {
|
||||
if (!instance) {
|
||||
return { canApply: false, reason: 'Equipment instance not found' };
|
||||
}
|
||||
|
||||
if (!design) {
|
||||
return { canApply: false, reason: 'Enchantment design not found' };
|
||||
}
|
||||
|
||||
if (currentAction !== 'meditate') {
|
||||
return { canApply: false, reason: 'Must be in meditate state' };
|
||||
}
|
||||
|
||||
if (!instance.tags?.includes('Ready for Enchantment')) {
|
||||
return { canApply: false, reason: 'Equipment must be prepared for enchanting' };
|
||||
}
|
||||
|
||||
if (instance.usedCapacity + design.totalCapacityUsed > instance.totalCapacity) {
|
||||
return { canApply: false, reason: 'Not enough capacity on equipment' };
|
||||
}
|
||||
|
||||
if (!instance) return { canApply: false, reason: 'Equipment instance not found' };
|
||||
if (!design) return { canApply: false, reason: 'Enchantment design not found' };
|
||||
if (currentAction !== 'meditate') return { canApply: false, reason: 'Must be in meditate state' };
|
||||
if (!instance.tags?.includes('Ready for Enchantment')) return { canApply: false, reason: 'Equipment must be prepared for enchanting' };
|
||||
if (instance.usedCapacity + design.totalCapacityUsed > instance.totalCapacity) return { canApply: false, reason: 'Not enough capacity on equipment' };
|
||||
return { canApply: true };
|
||||
}
|
||||
|
||||
@@ -51,21 +36,18 @@ export interface ApplicationCosts {
|
||||
export function calculateApplicationCosts(design: EnchantmentDesign): ApplicationCosts {
|
||||
const time = calculateApplicationTime(design);
|
||||
const manaPerHour = calculateApplicationManaPerHour(design);
|
||||
const manaPerTick = manaPerHour * 0.04; // HOURS_PER_TICK
|
||||
|
||||
const manaPerTick = manaPerHour * HOURS_PER_TICK;
|
||||
return { time, manaPerHour, manaPerTick };
|
||||
}
|
||||
|
||||
// ─── Application Progress ───────────────────────────────────────────────────
|
||||
|
||||
// Initialize application progress
|
||||
export function initializeApplicationProgress(
|
||||
equipmentInstanceId: string,
|
||||
designId: string,
|
||||
design: EnchantmentDesign
|
||||
): ApplicationProgress {
|
||||
const costs = calculateApplicationCosts(design);
|
||||
|
||||
return {
|
||||
equipmentInstanceId,
|
||||
designId,
|
||||
@@ -77,7 +59,13 @@ export function initializeApplicationProgress(
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate application progress after a tick
|
||||
// Free enchant chance per special effect
|
||||
const FREE_ENCHANT_CHANCES: Record<string, number> = {
|
||||
[SPECIAL_EFFECTS.ENCHANT_PRESERVATION]: 0.25,
|
||||
[SPECIAL_EFFECTS.THRIFTY_ENCHANTER]: 0.10,
|
||||
[SPECIAL_EFFECTS.OPTIMIZED_ENCHANTING]: 0.25,
|
||||
};
|
||||
|
||||
export interface ApplicationTickResult {
|
||||
progress: number;
|
||||
manaSpent: number;
|
||||
@@ -93,20 +81,14 @@ export function calculateApplicationTick(
|
||||
manaPerTick: number,
|
||||
computedEffects: ComputedEffects
|
||||
): ApplicationTickResult {
|
||||
let progress = currentProgress + 0.04;
|
||||
let progress = currentProgress + HOURS_PER_TICK;
|
||||
let manaSpent = currentManaSpent + manaPerTick;
|
||||
let manaConsumed = manaPerTick;
|
||||
let triggeredFreeEnchant = false;
|
||||
|
||||
let freeEnchantChance = 0;
|
||||
if (hasSpecial(computedEffects, SPECIAL_EFFECTS.ENCHANT_PRESERVATION)) {
|
||||
freeEnchantChance += 0.25;
|
||||
}
|
||||
if (hasSpecial(computedEffects, SPECIAL_EFFECTS.THRIFTY_ENCHANTER)) {
|
||||
freeEnchantChance += 0.10;
|
||||
}
|
||||
if (hasSpecial(computedEffects, SPECIAL_EFFECTS.OPTIMIZED_ENCHANTING)) {
|
||||
freeEnchantChance += 0.25;
|
||||
for (const [special, chance] of Object.entries(FREE_ENCHANT_CHANCES)) {
|
||||
if (hasSpecial(computedEffects, special)) freeEnchantChance += chance;
|
||||
}
|
||||
|
||||
if (freeEnchantChance > 0 && Math.random() < freeEnchantChance) {
|
||||
@@ -116,47 +98,32 @@ export function calculateApplicationTick(
|
||||
triggeredFreeEnchant = true;
|
||||
}
|
||||
|
||||
return {
|
||||
progress,
|
||||
manaSpent,
|
||||
manaConsumed,
|
||||
isComplete: progress >= required,
|
||||
triggeredFreeEnchant,
|
||||
};
|
||||
return { progress, manaSpent, manaConsumed, isComplete: progress >= required, triggeredFreeEnchant };
|
||||
}
|
||||
|
||||
// ─── Enchantment Application ────────────────────────────────────────────────
|
||||
|
||||
// Apply enchantments to equipment instance
|
||||
const PURE_ESSENCE_STACK_BONUS = 1.25;
|
||||
const PURE_ESSENCE_COST_CAP = 100;
|
||||
|
||||
export function applyEnchantments(
|
||||
instance: EquipmentInstance,
|
||||
design: EnchantmentDesign,
|
||||
computedEffects: ComputedEffects
|
||||
): {
|
||||
updatedInstance: EquipmentInstance;
|
||||
xpGained: number;
|
||||
logMessage: string;
|
||||
} {
|
||||
): { updatedInstance: EquipmentInstance; xpGained: number; logMessage: string } {
|
||||
const isPureEssenceActive = hasSpecial(computedEffects, SPECIAL_EFFECTS.PURE_ESSENCE);
|
||||
|
||||
const newEnchantments: AppliedEnchantment[] = design.effects.map(eff => {
|
||||
let stacks = eff.stacks;
|
||||
let actualCost = eff.capacityCost;
|
||||
|
||||
const effectDef = ENCHANTMENT_EFFECTS[eff.effectId];
|
||||
if (isPureEssenceActive && effectDef && effectDef.baseCapacityCost < 100) {
|
||||
stacks = Math.ceil(stacks * 1.25);
|
||||
}
|
||||
|
||||
const bonusStacks = isPureEssenceActive && effectDef && effectDef.baseCapacityCost < PURE_ESSENCE_COST_CAP;
|
||||
return {
|
||||
effectId: eff.effectId,
|
||||
stacks,
|
||||
actualCost,
|
||||
stacks: bonusStacks ? Math.ceil(eff.stacks * PURE_ESSENCE_STACK_BONUS) : eff.stacks,
|
||||
actualCost: eff.capacityCost,
|
||||
};
|
||||
});
|
||||
|
||||
const xpGained = calculateEnchantingXP(design.totalCapacityUsed);
|
||||
|
||||
const updatedInstance: EquipmentInstance = {
|
||||
...instance,
|
||||
enchantments: [...instance.enchantments, ...newEnchantments],
|
||||
@@ -176,15 +143,12 @@ export function updateEnchanterAttunement(
|
||||
attunements: Record<string, AttunementState>,
|
||||
xpGained: number
|
||||
): Record<string, AttunementState> {
|
||||
if (!attunements?.enchanter?.active || xpGained <= 0) {
|
||||
return attunements;
|
||||
}
|
||||
if (!attunements?.enchanter?.active || xpGained <= 0) return attunements;
|
||||
|
||||
const enchanterState = attunements.enchanter;
|
||||
let newXP = enchanterState.experience + xpGained;
|
||||
let newLevel = enchanterState.level;
|
||||
|
||||
|
||||
while (newLevel < MAX_ATTUNEMENT_LEVEL) {
|
||||
const xpNeeded = getAttunementXPForLevel(newLevel + 1);
|
||||
if (newXP >= xpNeeded) {
|
||||
@@ -197,11 +161,7 @@ export function updateEnchanterAttunement(
|
||||
|
||||
return {
|
||||
...attunements,
|
||||
enchanter: {
|
||||
...enchanterState,
|
||||
level: newLevel,
|
||||
experience: newXP,
|
||||
},
|
||||
enchanter: { ...enchanterState, level: newLevel, experience: newXP },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -222,7 +182,7 @@ export function resumeApplication() {
|
||||
// ─── Progress Calculations ──────────────────────────────────────────────────
|
||||
|
||||
export function getApplicationManaCostForTick(manaPerHour: number): number {
|
||||
return manaPerHour * 0.04;
|
||||
return manaPerHour * HOURS_PER_TICK;
|
||||
}
|
||||
|
||||
export function getApplicationRemainingTime(currentProgress: number, required: number): number {
|
||||
|
||||
@@ -6,67 +6,55 @@ import type { ComputedEffects } from './effects/upgrade-effects.types';
|
||||
import { calculateEnchantingXP } from './data/attunements';
|
||||
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from './data/enchantment-effects';
|
||||
import { hasSpecial, SPECIAL_EFFECTS } from './effects/special-effects';
|
||||
import { HOURS_PER_TICK } from './constants';
|
||||
import { EQUIPMENT_TYPES } from './data/equipment';
|
||||
|
||||
// Progress per tick expressed as a fraction of HOURS_PER_TICK
|
||||
const DESIGN_PROGRESS_PER_TICK = HOURS_PER_TICK;
|
||||
const HASTY_ENCHANTER_BONUS_MULTIPLIER = 0.25;
|
||||
|
||||
// ─── Design Creation & Calculation ──────────────────────────────────────────
|
||||
|
||||
// Validate effects for a design against equipment category
|
||||
export function validateDesignEffects(
|
||||
effects: DesignEffect[],
|
||||
equipmentTypeId: string,
|
||||
enchantingLevel: number
|
||||
): { valid: boolean; reason?: string } {
|
||||
if (enchantingLevel < 1) {
|
||||
return { valid: false, reason: 'Requires enchanting skill level 1' };
|
||||
}
|
||||
if (enchantingLevel < 1) return { valid: false, reason: 'Requires enchanting skill level 1' };
|
||||
|
||||
const equipType = EQUIPMENT_TYPES[equipmentTypeId];
|
||||
if (!equipType) {
|
||||
return { valid: false, reason: 'Invalid equipment type' };
|
||||
}
|
||||
const category = equipType.category;
|
||||
if (!category) {
|
||||
return { valid: false, reason: 'Invalid equipment category' };
|
||||
}
|
||||
if (!equipType || !equipType.category) return { valid: false, reason: 'Invalid equipment type or category' };
|
||||
|
||||
for (const eff of effects) {
|
||||
const effectDef = ENCHANTMENT_EFFECTS[eff.effectId];
|
||||
if (!effectDef) {
|
||||
return { valid: false, reason: `Unknown effect: ${eff.effectId}` };
|
||||
}
|
||||
if (!effectDef.allowedEquipmentCategories.includes(category)) {
|
||||
return { valid: false, reason: `Effect ${eff.effectId} not allowed on ${category}` };
|
||||
}
|
||||
if (eff.stacks > effectDef.maxStacks) {
|
||||
return { valid: false, reason: `Stacks exceed maximum for ${eff.effectId}` };
|
||||
if (!effectDef) return { valid: false, reason: `Unknown effect: ${eff.effectId}` };
|
||||
if (!effectDef.allowedEquipmentCategories.includes(equipType.category)) {
|
||||
return { valid: false, reason: `Effect ${eff.effectId} not allowed on ${equipType.category}` };
|
||||
}
|
||||
if (eff.stacks > effectDef.maxStacks) return { valid: false, reason: `Stacks exceed maximum for ${eff.effectId}` };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
// Create an enchantment design from validated inputs
|
||||
export function createEnchantmentDesign(
|
||||
name: string,
|
||||
equipmentType: string,
|
||||
effects: DesignEffect[],
|
||||
efficiencyBonus: number = 0
|
||||
): EnchantmentDesign {
|
||||
const totalCapacityUsed = calculateDesignCapacityCost(effects, efficiencyBonus);
|
||||
const designTime = calculateDesignTime(effects);
|
||||
|
||||
return {
|
||||
id: `design_${Date.now()}`,
|
||||
name,
|
||||
equipmentType,
|
||||
effects,
|
||||
totalCapacityUsed,
|
||||
designTime,
|
||||
totalCapacityUsed: calculateDesignCapacityCost(effects, efficiencyBonus),
|
||||
designTime: calculateDesignTime(effects),
|
||||
created: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Capacity Cost Calculation ──────────────────────────────────────────────
|
||||
// ─── Capacity & Time Calculations ───────────────────────────────────────────
|
||||
|
||||
export function calculateDesignCapacityCost(effects: DesignEffect[], efficiencyBonus: number = 0): number {
|
||||
return effects.reduce((total, eff) => total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus), 0);
|
||||
@@ -76,6 +64,25 @@ export function calculateTotalCapacityCost(design: EnchantmentDesign): number {
|
||||
return design.totalCapacityUsed;
|
||||
}
|
||||
|
||||
export function calculateDesignTime(effects: DesignEffect[]): number {
|
||||
let time = 1;
|
||||
for (const eff of effects) {
|
||||
if (ENCHANTMENT_EFFECTS[eff.effectId]) time += 0.5 * eff.stacks;
|
||||
}
|
||||
return time;
|
||||
}
|
||||
|
||||
export function getDesignTimeWithHaste(
|
||||
effects: DesignEffect[],
|
||||
isRepeatDesign: boolean,
|
||||
computedEffects: ComputedEffects
|
||||
): number {
|
||||
const time = calculateDesignTime(effects);
|
||||
return isRepeatDesign && hasSpecial(computedEffects, SPECIAL_EFFECTS.HASTY_ENCHANTER)
|
||||
? time * 0.75
|
||||
: time;
|
||||
}
|
||||
|
||||
// ─── XP & Progression ───────────────────────────────────────────────────────
|
||||
|
||||
export function calculateEnchantingXpFromDesign(design: EnchantmentDesign): number {
|
||||
@@ -88,37 +95,11 @@ export function calculateXpFromInstanceEnchantments(
|
||||
let totalXp = 0;
|
||||
for (const ench of instance.enchantments) {
|
||||
const effectDef = ENCHANTMENT_EFFECTS[ench.effectId];
|
||||
const baseCost = effectDef?.baseCapacityCost || 0;
|
||||
totalXp += calculateEnchantingXP(baseCost * ench.stacks);
|
||||
totalXp += calculateEnchantingXP((effectDef?.baseCapacityCost || 0) * ench.stacks);
|
||||
}
|
||||
return totalXp;
|
||||
}
|
||||
|
||||
// ─── Design Time Calculations ──────────────────────────────────────────────
|
||||
|
||||
export function calculateDesignTime(effects: DesignEffect[]): number {
|
||||
let time = 1;
|
||||
for (const eff of effects) {
|
||||
const effectDef = ENCHANTMENT_EFFECTS[eff.effectId];
|
||||
if (effectDef) {
|
||||
time += 0.5 * eff.stacks;
|
||||
}
|
||||
}
|
||||
return time;
|
||||
}
|
||||
|
||||
export function getDesignTimeWithHaste(
|
||||
effects: DesignEffect[],
|
||||
isRepeatDesign: boolean,
|
||||
computedEffects: ComputedEffects
|
||||
): number {
|
||||
let time = calculateDesignTime(effects);
|
||||
if (isRepeatDesign && hasSpecial(computedEffects, SPECIAL_EFFECTS.HASTY_ENCHANTER)) {
|
||||
time *= 0.75;
|
||||
}
|
||||
return time;
|
||||
}
|
||||
|
||||
// ─── Progress Calculations ──────────────────────────────────────────────────
|
||||
|
||||
export interface DesignProgressUpdate {
|
||||
@@ -128,21 +109,23 @@ export interface DesignProgressUpdate {
|
||||
timeBonus: number;
|
||||
}
|
||||
|
||||
const INSTANT_DESIGN_CHANCE = 0.10;
|
||||
|
||||
export function calculateDesignProgress(
|
||||
currentProgress: number,
|
||||
required: number,
|
||||
computedEffects: ComputedEffects,
|
||||
isRepeatDesign: boolean
|
||||
): DesignProgressUpdate {
|
||||
let progress = currentProgress + 0.04;
|
||||
let progress = currentProgress + DESIGN_PROGRESS_PER_TICK;
|
||||
let timeBonus = 0;
|
||||
|
||||
if (isRepeatDesign && hasSpecial(computedEffects, SPECIAL_EFFECTS.HASTY_ENCHANTER)) {
|
||||
timeBonus = 0.04 * 0.25;
|
||||
timeBonus = DESIGN_PROGRESS_PER_TICK * HASTY_ENCHANTER_BONUS_MULTIPLIER;
|
||||
progress += timeBonus;
|
||||
}
|
||||
|
||||
if (hasSpecial(computedEffects, SPECIAL_EFFECTS.INSTANT_DESIGNS) && Math.random() < 0.10) {
|
||||
if (hasSpecial(computedEffects, SPECIAL_EFFECTS.INSTANT_DESIGNS) && Math.random() < INSTANT_DESIGN_CHANCE) {
|
||||
progress = required;
|
||||
}
|
||||
|
||||
@@ -165,8 +148,7 @@ export function isSecondDesignSlotAvailable(
|
||||
): boolean {
|
||||
if (!designProgress && !designProgress2) return true;
|
||||
if (!designProgress && designProgress2) return false;
|
||||
if (designProgress && !designProgress2 && hasEnchantMastery) return true;
|
||||
return false;
|
||||
return !!(designProgress && !designProgress2 && hasEnchantMastery);
|
||||
}
|
||||
|
||||
// ─── Auto-save Completed Design ────────────────────────────────────────────
|
||||
@@ -181,13 +163,12 @@ export function createCompletedDesignFromProgress(
|
||||
},
|
||||
efficiencyBonus: number = 0
|
||||
): EnchantmentDesign {
|
||||
const totalCapacityCost = calculateDesignCapacityCost(progressData.effects, efficiencyBonus);
|
||||
return {
|
||||
id: progressData.designId,
|
||||
name: progressData.name,
|
||||
equipmentType: progressData.equipmentType,
|
||||
effects: progressData.effects,
|
||||
totalCapacityUsed: totalCapacityCost,
|
||||
totalCapacityUsed: calculateDesignCapacityCost(progressData.effects, efficiencyBonus),
|
||||
designTime: progressData.required,
|
||||
created: Date.now(),
|
||||
};
|
||||
@@ -206,13 +187,10 @@ export function filterDesignsByEquipment(
|
||||
equipment: { instanceId: string; totalCapacity: number; usedCapacity: number } | null
|
||||
): DesignWithCapacityInfo[] {
|
||||
if (!equipment) return [];
|
||||
const availableCapacity = equipment.totalCapacity - equipment.usedCapacity;
|
||||
return designs.map(design => ({
|
||||
design,
|
||||
fitsInEquipment: designFitsInEquipment(design, equipment),
|
||||
availableCapacity: equipment.totalCapacity - equipment.usedCapacity,
|
||||
fitsInEquipment: (equipment.usedCapacity || 0) + design.totalCapacityUsed <= equipment.totalCapacity,
|
||||
availableCapacity,
|
||||
}));
|
||||
}
|
||||
|
||||
function designFitsInEquipment(design: EnchantmentDesign, instance: { usedCapacity: number; totalCapacity: number }): boolean {
|
||||
return (instance.usedCapacity || 0) + design.totalCapacityUsed <= instance.totalCapacity;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ import { CRAFTING_RECIPES, canCraftRecipe, type CraftingRecipe } from './data/cr
|
||||
import { EQUIPMENT_TYPES } from './data/equipment';
|
||||
import { ok, fail, ErrorCode } from './utils/result';
|
||||
import type { Result } from './utils/result';
|
||||
import { HOURS_PER_TICK } from './constants';
|
||||
|
||||
const MANA_REFUND_RATE = 0.5;
|
||||
|
||||
// ─── Equipment Crafting Validation ──────────────────────────────────────────
|
||||
|
||||
// Check if equipment crafting can start
|
||||
export function canStartEquipmentCrafting(
|
||||
blueprintId: string,
|
||||
hasBlueprint: boolean,
|
||||
@@ -17,38 +19,27 @@ export function canStartEquipmentCrafting(
|
||||
currentMana: number,
|
||||
currentAction: string
|
||||
): { canCraft: boolean; reason?: string; recipe?: CraftingRecipe; missingMaterials?: Record<string, number>; missingMana?: number } {
|
||||
if (currentAction !== 'meditate') {
|
||||
return { canCraft: false, reason: 'Must be in meditate state' };
|
||||
}
|
||||
if (currentAction !== 'meditate') return { canCraft: false, reason: 'Must be in meditate state' };
|
||||
|
||||
const recipe = CRAFTING_RECIPES[blueprintId];
|
||||
if (!recipe) {
|
||||
return { canCraft: false, reason: 'Invalid blueprint' };
|
||||
}
|
||||
|
||||
if (!hasBlueprint) {
|
||||
return { canCraft: false, reason: 'Blueprint not acquired' };
|
||||
}
|
||||
if (!recipe) return { canCraft: false, reason: 'Invalid blueprint' };
|
||||
if (!hasBlueprint) return { canCraft: false, reason: 'Blueprint not acquired' };
|
||||
|
||||
const { canCraft, missingMaterials } = canCraftRecipe(recipe, materials, currentMana);
|
||||
if (canCraft) return { canCraft: true, recipe };
|
||||
|
||||
if (!canCraft) {
|
||||
const missingMana = Math.max(0, recipe.manaCost - currentMana);
|
||||
return {
|
||||
canCraft: false,
|
||||
reason: missingMana > 0 ? 'Insufficient mana' : 'Missing materials',
|
||||
recipe,
|
||||
missingMaterials,
|
||||
missingMana: missingMana > 0 ? missingMana : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { canCraft: true, recipe };
|
||||
const missingManaAmount = Math.max(0, recipe.manaCost - currentMana);
|
||||
return {
|
||||
canCraft: false,
|
||||
reason: missingManaAmount > 0 ? 'Insufficient mana' : 'Missing materials',
|
||||
recipe,
|
||||
missingMaterials,
|
||||
missingMana: missingManaAmount > 0 ? missingManaAmount : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Equipment Crafting Execution ───────────────────────────────────────────
|
||||
|
||||
// Deduct crafting costs and initialize progress
|
||||
export interface CraftingInitResult {
|
||||
recipe: CraftingRecipe;
|
||||
newMaterials: Record<string, number>;
|
||||
@@ -63,108 +54,80 @@ export function initializeEquipmentCrafting(
|
||||
): CraftingInitResult {
|
||||
const recipe = CRAFTING_RECIPES[blueprintId];
|
||||
|
||||
// Deduct materials
|
||||
const newMaterials = { ...materials };
|
||||
for (const [matId, amount] of Object.entries(recipe.materials)) {
|
||||
newMaterials[matId] = (newMaterials[matId] || 0) - amount;
|
||||
if (newMaterials[matId] <= 0) {
|
||||
delete newMaterials[matId];
|
||||
}
|
||||
if (newMaterials[matId] <= 0) delete newMaterials[matId];
|
||||
}
|
||||
|
||||
// Create progress
|
||||
const progress: EquipmentCraftingProgress = {
|
||||
blueprintId,
|
||||
equipmentTypeId: recipe.equipmentTypeId,
|
||||
progress: 0,
|
||||
required: recipe.craftTime,
|
||||
manaSpent: recipe.manaCost,
|
||||
};
|
||||
|
||||
return {
|
||||
recipe,
|
||||
newMaterials,
|
||||
manaCost: recipe.manaCost,
|
||||
progress,
|
||||
progress: {
|
||||
blueprintId,
|
||||
equipmentTypeId: recipe.equipmentTypeId,
|
||||
progress: 0,
|
||||
required: recipe.craftTime,
|
||||
manaSpent: recipe.manaCost,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Crafting Progress ──────────────────────────────────────────────────────
|
||||
|
||||
// Calculate crafting progress after a tick
|
||||
export interface CraftingTickResult {
|
||||
progress: number;
|
||||
isComplete: boolean;
|
||||
}
|
||||
|
||||
export function calculateCraftingTick(currentProgress: number, required: number): CraftingTickResult {
|
||||
const progress = currentProgress + 0.04; // HOURS_PER_TICK
|
||||
return {
|
||||
progress,
|
||||
isComplete: progress >= required,
|
||||
};
|
||||
const progress = currentProgress + HOURS_PER_TICK;
|
||||
return { progress, isComplete: progress >= required };
|
||||
}
|
||||
|
||||
// ─── Crafting Completion ───────────────────────────────────────────────────
|
||||
|
||||
// Create equipment instance from completed crafting
|
||||
const BASE_EQUIPMENT_QUALITY = 100;
|
||||
|
||||
export function completeEquipmentCrafting(
|
||||
blueprintId: string,
|
||||
recipe: CraftingRecipe
|
||||
): Result<{
|
||||
instanceId: string;
|
||||
instance: EquipmentInstance;
|
||||
logMessage: string;
|
||||
}> {
|
||||
): Result<{ instanceId: string; instance: EquipmentInstance; logMessage: string }> {
|
||||
const equipType = EQUIPMENT_TYPES[recipe.equipmentTypeId];
|
||||
if (!equipType) {
|
||||
return fail(ErrorCode.INVALID_EQUIPMENT_TYPE, `Invalid equipment type: ${recipe.equipmentTypeId}`);
|
||||
}
|
||||
if (!equipType) return fail(ErrorCode.INVALID_EQUIPMENT_TYPE, `Invalid equipment type: ${recipe.equipmentTypeId}`);
|
||||
|
||||
const instanceId = `equip_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const newInstance: EquipmentInstance = {
|
||||
instanceId,
|
||||
typeId: recipe.equipmentTypeId,
|
||||
name: recipe.name,
|
||||
enchantments: [],
|
||||
usedCapacity: 0,
|
||||
totalCapacity: equipType.baseCapacity,
|
||||
rarity: recipe.rarity,
|
||||
quality: 100,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
return ok({
|
||||
instanceId,
|
||||
instance: newInstance,
|
||||
instance: {
|
||||
instanceId,
|
||||
typeId: recipe.equipmentTypeId,
|
||||
name: recipe.name,
|
||||
enchantments: [],
|
||||
usedCapacity: 0,
|
||||
totalCapacity: equipType.baseCapacity,
|
||||
rarity: recipe.rarity,
|
||||
quality: BASE_EQUIPMENT_QUALITY,
|
||||
tags: [],
|
||||
},
|
||||
logMessage: `🔨 Crafted ${recipe.name}!`,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Crafting Cancellation ──────────────────────────────────────────────────
|
||||
|
||||
// Cancel active crafting and refund partial resources
|
||||
export interface CraftingCancelResult {
|
||||
manaRefund: number;
|
||||
logMessage: string;
|
||||
}
|
||||
|
||||
export function cancelEquipmentCrafting(_blueprintId: string, manaSpent: number): CraftingCancelResult {
|
||||
export function cancelEquipmentCrafting(blueprintId: string, manaSpent: number): CraftingCancelResult {
|
||||
const recipe = CRAFTING_RECIPES[blueprintId];
|
||||
if (!recipe) {
|
||||
return {
|
||||
manaRefund: 0,
|
||||
logMessage: 'Invalid crafting recipe.',
|
||||
};
|
||||
}
|
||||
if (!recipe) return { manaRefund: 0, logMessage: 'Invalid crafting recipe.' };
|
||||
|
||||
// Refund 50% of mana
|
||||
const manaRefund = Math.floor(manaSpent * 0.5);
|
||||
|
||||
return {
|
||||
manaRefund,
|
||||
logMessage: `🚫 Equipment crafting cancelled. Refunded ${manaRefund} mana.`,
|
||||
};
|
||||
const manaRefund = Math.floor(manaSpent * MANA_REFUND_RATE);
|
||||
return { manaRefund, logMessage: `🚫 Equipment crafting cancelled. Refunded ${manaRefund} mana.` };
|
||||
}
|
||||
|
||||
// ─── Recipe Information ─────────────────────────────────────────────────────
|
||||
@@ -179,23 +142,16 @@ export function getCraftableRecipes(
|
||||
currentMana: number
|
||||
): CraftingRecipe[] {
|
||||
const craftable: CraftingRecipe[] = [];
|
||||
|
||||
for (const blueprintId of blueprints) {
|
||||
const recipe = CRAFTING_RECIPES[blueprintId];
|
||||
if (!recipe) continue;
|
||||
|
||||
const { canCraft } = canCraftRecipe(recipe, materials, currentMana);
|
||||
if (canCraft) {
|
||||
craftable.push(recipe);
|
||||
}
|
||||
if (canCraftRecipe(recipe, materials, currentMana).canCraft) craftable.push(recipe);
|
||||
}
|
||||
|
||||
return craftable;
|
||||
}
|
||||
|
||||
// ─── Material Management ────────────────────────────────────────────────────
|
||||
|
||||
// Delete materials from inventory
|
||||
export function deleteMaterials(materialId: string, amount: number, materials: Record<string, number>): {
|
||||
newMaterials: Record<string, number>;
|
||||
deleted: number;
|
||||
@@ -204,25 +160,18 @@ export function deleteMaterials(materialId: string, amount: number, materials: R
|
||||
const deleted = Math.min(amount, currentAmount);
|
||||
const remaining = Math.max(0, currentAmount - amount);
|
||||
const newMaterials = { ...materials };
|
||||
|
||||
if (remaining <= 0) {
|
||||
delete newMaterials[materialId];
|
||||
} else {
|
||||
newMaterials[materialId] = remaining;
|
||||
}
|
||||
|
||||
return {
|
||||
newMaterials,
|
||||
deleted,
|
||||
};
|
||||
return { newMaterials, deleted };
|
||||
}
|
||||
|
||||
// Get total material count
|
||||
export function getMaterialCount(materials: Record<string, number>, materialId: string): number {
|
||||
return materials[materialId] || 0;
|
||||
}
|
||||
|
||||
// Add materials to inventory
|
||||
export function addMaterials(materials: Record<string, number>, materialId: string, amount: number): Record<string, number> {
|
||||
const newMaterials = { ...materials };
|
||||
newMaterials[materialId] = (newMaterials[materialId] || 0) + amount;
|
||||
|
||||
@@ -3,26 +3,21 @@
|
||||
|
||||
import type { EquipmentInstance, PreparationProgress } from './types';
|
||||
import { calculatePrepTime, calculatePrepManaCost, calculateManaPerHourForPrep } from './crafting-utils';
|
||||
import { HOURS_PER_TICK } from './constants';
|
||||
|
||||
// ─── Preparation Validation ─────────────────────────────────────────────────
|
||||
|
||||
// Check if an equipment instance can be prepared
|
||||
export function canPrepareEquipment(
|
||||
instance: EquipmentInstance | undefined,
|
||||
currentTags: string[]
|
||||
): { canPrepare: boolean; reason?: string } {
|
||||
if (!instance) {
|
||||
return { canPrepare: false, reason: 'Equipment instance not found' };
|
||||
}
|
||||
|
||||
if (currentTags.includes('Ready for Enchantment')) {
|
||||
return { canPrepare: false, reason: 'Equipment is already prepared for enchanting' };
|
||||
}
|
||||
|
||||
if (!instance) return { canPrepare: false, reason: 'Equipment instance not found' };
|
||||
if (currentTags.includes('Ready for Enchantment')) return { canPrepare: false, reason: 'Equipment is already prepared for enchanting' };
|
||||
return { canPrepare: true };
|
||||
}
|
||||
|
||||
// Calculate preparation resource costs
|
||||
// ─── Preparation Costs ──────────────────────────────────────────────────────
|
||||
|
||||
export interface PreparationCosts {
|
||||
time: number;
|
||||
manaTotal: number;
|
||||
@@ -34,30 +29,20 @@ export function calculatePreparationCosts(totalCapacity: number): PreparationCos
|
||||
const time = calculatePrepTime(totalCapacity);
|
||||
const manaTotal = calculatePrepManaCost(totalCapacity);
|
||||
const manaPerHour = calculateManaPerHourForPrep(totalCapacity, time);
|
||||
const manaPerTick = manaPerHour * 0.04; // HOURS_PER_TICK
|
||||
|
||||
return { time, manaTotal, manaPerHour, manaPerTick };
|
||||
return { time, manaTotal, manaPerHour, manaPerTick: manaPerHour * HOURS_PER_TICK };
|
||||
}
|
||||
|
||||
// ─── Preparation Progress ───────────────────────────────────────────────────
|
||||
|
||||
// Initialize preparation progress
|
||||
export function initializePreparationProgress(
|
||||
equipmentInstanceId: string,
|
||||
totalCapacity: number,
|
||||
manaCostPaid: number = 0
|
||||
): PreparationProgress {
|
||||
const costs = calculatePreparationCosts(totalCapacity);
|
||||
|
||||
return {
|
||||
equipmentInstanceId,
|
||||
progress: 0,
|
||||
required: costs.time,
|
||||
manaCostPaid,
|
||||
};
|
||||
return { equipmentInstanceId, progress: 0, required: costs.time, manaCostPaid };
|
||||
}
|
||||
|
||||
// Calculate updated preparation progress after a tick
|
||||
// ─── Preparation Tick ───────────────────────────────────────────────────────
|
||||
|
||||
export interface PreparationTickResult {
|
||||
progress: number;
|
||||
manaCostPaid: number;
|
||||
@@ -68,15 +53,14 @@ export interface PreparationTickResult {
|
||||
export function calculatePreparationTick(
|
||||
currentProgress: number,
|
||||
required: number,
|
||||
currentManaCostPaid: number,
|
||||
manaPerTick: number
|
||||
): PreparationTickResult {
|
||||
const progress = currentProgress + 0.04; // HOURS_PER_TICK
|
||||
const progress = currentProgress + HOURS_PER_TICK;
|
||||
const manaConsumed = manaPerTick;
|
||||
const manaCostPaid = manaPerTick; // Accumulated
|
||||
|
||||
return {
|
||||
progress,
|
||||
manaCostPaid,
|
||||
manaCostPaid: currentManaCostPaid + manaConsumed,
|
||||
manaConsumed,
|
||||
isComplete: progress >= required,
|
||||
};
|
||||
@@ -84,51 +68,40 @@ export function calculatePreparationTick(
|
||||
|
||||
// ─── Preparation Completion ─────────────────────────────────────────────────
|
||||
|
||||
// Apply preparation completion to equipment instance
|
||||
const BASE_DISENCHANT_RECOVERY_RATE = 0.1;
|
||||
const DISENCHANT_RECOVERY_PER_LEVEL = 0.2;
|
||||
|
||||
export function completePreparation(
|
||||
instance: EquipmentInstance,
|
||||
_manaSpent: number
|
||||
): {
|
||||
updatedInstance: EquipmentInstance;
|
||||
manaRecovered: number;
|
||||
logMessage: string;
|
||||
} {
|
||||
// Calculate mana recovery from disenchanting (disenchanting skill removed - Bug 13)
|
||||
const disenchantLevel = 0;
|
||||
const recoveryRate = 0.1 + disenchantLevel * 0.2; // 10% base + 20% per level
|
||||
|
||||
disenchantLevel: number = 0
|
||||
): { updatedInstance: EquipmentInstance; manaRecovered: number; logMessage: string } {
|
||||
const recoveryRate = BASE_DISENCHANT_RECOVERY_RATE + disenchantLevel * DISENCHANT_RECOVERY_PER_LEVEL;
|
||||
let totalRecovered = 0;
|
||||
for (const ench of instance.enchantments) {
|
||||
totalRecovered += Math.floor(ench.actualCost * recoveryRate);
|
||||
}
|
||||
|
||||
const updatedInstance: EquipmentInstance = {
|
||||
...instance,
|
||||
enchantments: [],
|
||||
usedCapacity: 0,
|
||||
rarity: 'common',
|
||||
tags: [...(instance.tags || []), 'Ready for Enchantment'],
|
||||
};
|
||||
|
||||
return {
|
||||
updatedInstance,
|
||||
updatedInstance: {
|
||||
...instance,
|
||||
enchantments: [],
|
||||
usedCapacity: 0,
|
||||
rarity: 'common',
|
||||
tags: [...(instance.tags || []), 'Ready for Enchantment'],
|
||||
},
|
||||
manaRecovered: totalRecovered,
|
||||
logMessage: `✅ Equipment prepared for enchanting! Recovered ${totalRecovered} mana.`,
|
||||
};
|
||||
}
|
||||
|
||||
// Cancel preparation (no resource recovery for preparation itself)
|
||||
export function cancelPreparation() {
|
||||
return {
|
||||
logMessage: 'Preparation cancelled.',
|
||||
};
|
||||
return { logMessage: 'Preparation cancelled.' };
|
||||
}
|
||||
|
||||
// ─── Preparation State Calculations ─────────────────────────────────────────
|
||||
|
||||
export function getPreparationManaCostForTick(instance: EquipmentInstance): number {
|
||||
const costs = calculatePreparationCosts(instance.totalCapacity);
|
||||
return costs.manaPerTick;
|
||||
return calculatePreparationCosts(instance.totalCapacity).manaPerTick;
|
||||
}
|
||||
|
||||
export function getPreparationRemainingTime(currentProgress: number, required: number): number {
|
||||
|
||||
@@ -2,8 +2,22 @@
|
||||
// Dynamic computation functions that depend on special effects
|
||||
|
||||
import type { ComputedEffects } from './upgrade-effects.types';
|
||||
|
||||
import { SPECIAL_EFFECTS, hasSpecial } from './special-effects';
|
||||
|
||||
// Threshold ratios for mana-dependent effects (currentMana / maxMana)
|
||||
const MANA_HIGH_THRESHOLD = 0.75;
|
||||
const MANA_OVERPOWER_THRESHOLD = 0.8;
|
||||
const MANA_BERSERKER_THRESHOLD = 0.5;
|
||||
const MANA_LOW_THRESHOLD = 0.25;
|
||||
const MANA_CRITICAL_THRESHOLD = 0.1;
|
||||
|
||||
// Regen multipliers for mana-dependent thresholds
|
||||
const MANA_TORRENT_MULTIPLIER = 1.5;
|
||||
const MANA_CRISIS_MULTIPLIER = 1.5; // Desperate / Despair Wells
|
||||
const PANIC_RESERVE_MULTIPLIER = 2.0;
|
||||
const REGEN_BOOST_PER_100_MANA = 0.1;
|
||||
|
||||
/**
|
||||
* Compute regen with special effects that depend on dynamic values
|
||||
*/
|
||||
@@ -15,60 +29,51 @@ export function computeDynamicRegen(
|
||||
incursionStrength: number
|
||||
): number {
|
||||
let regen = baseRegen;
|
||||
|
||||
// Mana Cascade: +0.1 regen per 100 max mana
|
||||
|
||||
// Per-100-max-mana regen bonuses
|
||||
const manaHundreds = Math.floor(maxMana / 100);
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_CASCADE)) {
|
||||
regen += Math.floor(maxMana / 100) * 0.1;
|
||||
regen += manaHundreds * REGEN_BOOST_PER_100_MANA;
|
||||
}
|
||||
|
||||
// Mana Waterfall: +0.25 regen per 100 max mana (upgraded cascade)
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_WATERFALL)) {
|
||||
regen += Math.floor(maxMana / 100) * 0.25;
|
||||
regen += manaHundreds * 0.25;
|
||||
}
|
||||
|
||||
// Mana Torrent: +50% regen when above 75% mana
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_TORRENT) && currentMana > maxMana * 0.75) {
|
||||
regen *= 1.5;
|
||||
}
|
||||
|
||||
// Desperate Wells / Despair Wells: +50% regen when below 25% mana
|
||||
if ((hasSpecial(effects, SPECIAL_EFFECTS.DESPERATE_WELLS) || hasSpecial(effects, SPECIAL_EFFECTS.DESPAIR_WELLS)) && currentMana < maxMana * 0.25) {
|
||||
regen *= 1.5;
|
||||
}
|
||||
|
||||
// Panic Reserve: +100% regen when below 10% mana
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.PANIC_RESERVE) && currentMana < maxMana * 0.1) {
|
||||
regen *= 2.0;
|
||||
}
|
||||
|
||||
// Deep Reserve: +0.5 regen per 100 max mana
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.DEEP_RESERVE)) {
|
||||
regen += Math.floor(maxMana / 100) * 0.5;
|
||||
regen += manaHundreds * 0.5;
|
||||
}
|
||||
|
||||
// Mana Core: 0.5% of max mana added as regen
|
||||
|
||||
// Fractional max-mana regen
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_CORE)) {
|
||||
regen += maxMana * 0.005;
|
||||
}
|
||||
|
||||
// Mana Tide: Regen pulses ±50% (sinusoidal based on time)
|
||||
|
||||
// Mana Tide: sinusoidal pulse
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_TIDE)) {
|
||||
regen *= (1.0 + 0.5 * Math.sin(Date.now() / 10000));
|
||||
}
|
||||
|
||||
// Eternal Flow: Regen immune to ALL penalties
|
||||
|
||||
// Mana-ratio-dependent multipliers
|
||||
const manaRatio = currentMana / maxMana;
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.MANA_TORRENT) && manaRatio > MANA_HIGH_THRESHOLD) {
|
||||
regen *= MANA_TORRENT_MULTIPLIER;
|
||||
}
|
||||
if ((hasSpecial(effects, SPECIAL_EFFECTS.DESPERATE_WELLS) || hasSpecial(effects, SPECIAL_EFFECTS.DESPAIR_WELLS)) && manaRatio < MANA_LOW_THRESHOLD) {
|
||||
regen *= MANA_CRISIS_MULTIPLIER;
|
||||
}
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.PANIC_RESERVE) && manaRatio < MANA_CRITICAL_THRESHOLD) {
|
||||
regen *= PANIC_RESERVE_MULTIPLIER;
|
||||
}
|
||||
|
||||
// Eternal Flow: skip incursion + multiplier below
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.ETERNAL_FLOW)) {
|
||||
return regen * effects.regenMultiplier;
|
||||
}
|
||||
|
||||
// Steady Stream: Regen immune to incursion (skip incursion penalty only)
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.STEADY_STREAM)) {
|
||||
// incursion penalty is skipped, but regenMultiplier still applies below
|
||||
} else {
|
||||
// Apply incursion penalty
|
||||
|
||||
// Steady Stream: skip incursion only
|
||||
if (!hasSpecial(effects, SPECIAL_EFFECTS.STEADY_STREAM)) {
|
||||
regen *= (1 - incursionStrength);
|
||||
}
|
||||
|
||||
|
||||
return regen * effects.regenMultiplier;
|
||||
}
|
||||
|
||||
@@ -79,46 +84,7 @@ export function computeDynamicClickMana(
|
||||
effects: ComputedEffects,
|
||||
baseClickMana: number
|
||||
): number {
|
||||
let clickMana = baseClickMana;
|
||||
|
||||
// Mana Echo: 10% chance to gain double mana from clicks
|
||||
// Note: The chance is handled in the click handler, this just returns the base
|
||||
// The click handler should check hasSpecial and apply the 10% chance
|
||||
|
||||
// Mana Genesis: Generate 1% of max mana per hour passively
|
||||
// This is handled in the game loop (store.ts), not here
|
||||
|
||||
// Mana Heart: +10% max mana per loop (permanent)
|
||||
// This is applied during loop reset in store.ts
|
||||
|
||||
return Math.floor((clickMana + effects.clickManaBonus) * effects.clickManaMultiplier);
|
||||
return Math.floor((baseClickMana + effects.clickManaBonus) * effects.clickManaMultiplier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute damage with special effects
|
||||
*/
|
||||
export function computeDynamicDamage(
|
||||
effects: ComputedEffects,
|
||||
baseDamage: number,
|
||||
_floorHPPct: number,
|
||||
currentMana: number,
|
||||
maxMana: number
|
||||
): number {
|
||||
let damage = baseDamage * effects.baseDamageMultiplier;
|
||||
|
||||
// Overpower: +50% damage when mana above 80%
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.OVERPOWER) && currentMana >= maxMana * 0.8) {
|
||||
damage *= 1.5;
|
||||
}
|
||||
|
||||
// Berserker: +50% damage when below 50% mana
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && currentMana < maxMana * 0.5) {
|
||||
damage *= 1.5;
|
||||
}
|
||||
|
||||
// Combo Master: Every 5th attack deals 3x damage
|
||||
// Note: The hit counter is tracked in game state, this just returns the multiplier
|
||||
// The combat handler should check hasSpecial and the hit count
|
||||
|
||||
return damage + effects.baseDamageBonus;
|
||||
}
|
||||
|
||||
|
||||
+241
-260
@@ -82,277 +82,258 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
|
||||
if (ctx.ui.gameOver || ctx.ui.paused) return;
|
||||
|
||||
// ── Phase 2: Compute — derive all updates ───────────────────────────
|
||||
const writes: TickWrites = { logs: [] };
|
||||
const addLog = (msg: string) => writes.logs.push(msg);
|
||||
|
||||
// Compute equipment and discipline effects
|
||||
const equipmentEffects = computeEquipmentEffects(
|
||||
ctx.crafting.equipmentInstances || {},
|
||||
ctx.crafting.equippedInstances || {}
|
||||
);
|
||||
const disciplineEffects = computeDisciplineEffects();
|
||||
const allSpecials = new Set<string>([
|
||||
...equipmentEffects.specials,
|
||||
...disciplineEffects.specials,
|
||||
]);
|
||||
const effects = { specials: allSpecials } as ComputedEffects;
|
||||
|
||||
const maxMana = computeMaxMana(
|
||||
{ skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {} },
|
||||
undefined,
|
||||
disciplineEffects,
|
||||
);
|
||||
const baseRegen = computeRegen(
|
||||
{ skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, attunements: {} },
|
||||
undefined,
|
||||
disciplineEffects,
|
||||
);
|
||||
|
||||
// Time progression
|
||||
let hour = ctx.game.hour + HOURS_PER_TICK;
|
||||
let day = ctx.game.day;
|
||||
if (hour >= 24) {
|
||||
hour -= 24;
|
||||
day += 1;
|
||||
}
|
||||
|
||||
// Check for loop end
|
||||
if (day > MAX_DAY) {
|
||||
const insightGained = calcInsight({
|
||||
maxFloorReached: ctx.combat.maxFloorReached,
|
||||
totalManaGathered: ctx.mana.totalManaGathered,
|
||||
signedPacts: ctx.prestige.signedPacts,
|
||||
prestigeUpgrades: ctx.prestige.prestigeUpgrades,
|
||||
skills: {},
|
||||
}, disciplineEffects);
|
||||
|
||||
addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`);
|
||||
writes.ui = { ...(writes.ui || {}), gameOver: true, victory: false };
|
||||
writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained };
|
||||
writes.game = { day, hour };
|
||||
applyTickWrites(writes, {
|
||||
// Shared setters object — used by every applyTickWrites call below
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const storeSetters = {
|
||||
setGame: set,
|
||||
setUI: (w) => useUIStore.setState(w),
|
||||
setPrestige: (w) => usePrestigeStore.setState(w),
|
||||
setMana: (w) => useManaStore.setState(w),
|
||||
setCombat: (w) => useCombatStore.setState(w),
|
||||
setCrafting: (w) => useCraftingStore.setState(w),
|
||||
setAttunement: (w) => useAttunementStore.setState(w),
|
||||
setDiscipline: (w) => useDisciplineStore.setState(w),
|
||||
addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setUI: (w: any) => useUIStore.setState(w),
|
||||
setPrestige: (w: any) => usePrestigeStore.setState(w),
|
||||
setMana: (w: any) => useManaStore.setState(w),
|
||||
setCombat: (w: any) => useCombatStore.setState(w),
|
||||
setCrafting: (w: any) => useCraftingStore.setState(w),
|
||||
setAttunement: (w: any) => useAttunementStore.setState(w),
|
||||
setDiscipline: (w: any) => useDisciplineStore.setState(w),
|
||||
addLogs: (msgs: string[]) => msgs.forEach((m) => useUIStore.getState().addLog(m)),
|
||||
};
|
||||
|
||||
// Check for victory
|
||||
if (ctx.combat.maxFloorReached >= 100 && ctx.prestige.signedPacts.includes(100)) {
|
||||
const insightGained = calcInsight({
|
||||
maxFloorReached: ctx.combat.maxFloorReached,
|
||||
totalManaGathered: ctx.mana.totalManaGathered,
|
||||
signedPacts: ctx.prestige.signedPacts,
|
||||
prestigeUpgrades: ctx.prestige.prestigeUpgrades,
|
||||
skills: {},
|
||||
}, disciplineEffects) * 3;
|
||||
// ── Phase 2: Compute — derive all updates ───────────────────────────
|
||||
const writes: TickWrites = { logs: [] };
|
||||
const addLog = (msg: string) => writes.logs.push(msg);
|
||||
|
||||
addLog(`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`);
|
||||
writes.ui = { ...(writes.ui || {}), gameOver: true, victory: true };
|
||||
writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained };
|
||||
applyTickWrites(writes, {
|
||||
setGame: set,
|
||||
setUI: (w) => useUIStore.setState(w),
|
||||
setPrestige: (w) => usePrestigeStore.setState(w),
|
||||
setMana: (w) => useManaStore.setState(w),
|
||||
setCombat: (w) => useCombatStore.setState(w),
|
||||
setCrafting: (w) => useCraftingStore.setState(w),
|
||||
setAttunement: (w) => useAttunementStore.setState(w),
|
||||
setDiscipline: (w) => useDisciplineStore.setState(w),
|
||||
addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Compute equipment and discipline effects
|
||||
const equipmentEffects = computeEquipmentEffects(
|
||||
ctx.crafting.equipmentInstances || {},
|
||||
ctx.crafting.equippedInstances || {}
|
||||
);
|
||||
const disciplineEffects = computeDisciplineEffects();
|
||||
const allSpecials = new Set<string>([
|
||||
...equipmentEffects.specials,
|
||||
...disciplineEffects.specials,
|
||||
]);
|
||||
const effects = { specials: allSpecials } as ComputedEffects;
|
||||
|
||||
// Incursion
|
||||
const incursionStrength = getIncursionStrength(day, hour);
|
||||
|
||||
// Meditation bonus tracking
|
||||
let meditateTicks = ctx.mana.meditateTicks;
|
||||
let meditationMultiplier = 1;
|
||||
|
||||
if (ctx.combat.currentAction === 'meditate') {
|
||||
meditateTicks++;
|
||||
meditationMultiplier = getMeditationBonus(meditateTicks, {}, 1);
|
||||
} else {
|
||||
meditateTicks = 0;
|
||||
}
|
||||
|
||||
// Calculate total attunement conversion per tick
|
||||
let totalConversionPerTick = 0;
|
||||
Object.entries(ctx.attunement.attunements).forEach(([id, state]) => {
|
||||
if (!state.active) return;
|
||||
const def = ATTUNEMENTS_DEF[id];
|
||||
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
|
||||
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
||||
totalConversionPerTick += scaledRate * HOURS_PER_TICK;
|
||||
});
|
||||
|
||||
// Calculate effective regen
|
||||
const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick);
|
||||
|
||||
// Mana regeneration
|
||||
let rawMana = Math.min(ctx.mana.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
|
||||
let elements = { ...ctx.mana.elements };
|
||||
|
||||
// Apply attunement conversion
|
||||
Object.entries(ctx.attunement.attunements).forEach(([id, state]) => {
|
||||
if (!state.active) return;
|
||||
const def = ATTUNEMENTS_DEF[id];
|
||||
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
|
||||
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
||||
const conversionThisTick = scaledRate * HOURS_PER_TICK;
|
||||
if (elements[def.primaryManaType]) {
|
||||
elements[def.primaryManaType].current = Math.min(
|
||||
elements[def.primaryManaType].max,
|
||||
elements[def.primaryManaType].current + conversionThisTick
|
||||
);
|
||||
}
|
||||
});
|
||||
let totalManaGathered = ctx.mana.totalManaGathered;
|
||||
|
||||
// Convert action — delegate to manaStore
|
||||
if (ctx.combat.currentAction === 'convert') {
|
||||
const convertResult = useManaStore.getState().processConvertAction(rawMana);
|
||||
if (convertResult) {
|
||||
rawMana = convertResult.rawMana;
|
||||
elements = convertResult.elements;
|
||||
}
|
||||
}
|
||||
|
||||
// Pact ritual progress
|
||||
if (ctx.prestige.pactRitualFloor !== null) {
|
||||
const guardian = getGuardianForFloor(ctx.prestige.pactRitualFloor);
|
||||
if (guardian) {
|
||||
const pactAffinityBonus = 1 - (ctx.prestige.prestigeUpgrades.pactAffinity || 0) * 0.1;
|
||||
const requiredTime = guardian.pactTime * pactAffinityBonus;
|
||||
const newProgress = ctx.prestige.pactRitualProgress + HOURS_PER_TICK;
|
||||
|
||||
if (newProgress >= requiredTime) {
|
||||
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
|
||||
writes.prestige = {
|
||||
...(writes.prestige || {}),
|
||||
signedPacts: [...ctx.prestige.signedPacts, ctx.prestige.pactRitualFloor],
|
||||
defeatedGuardians: ctx.prestige.defeatedGuardians.filter(f => f !== ctx.prestige.pactRitualFloor),
|
||||
pactRitualFloor: null,
|
||||
pactRitualProgress: 0,
|
||||
};
|
||||
} else {
|
||||
writes.prestige = {
|
||||
...(writes.prestige || {}),
|
||||
pactRitualProgress: newProgress,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discipline tick — process active disciplines (XP accrual + mana drain)
|
||||
const disciplineResult = useDisciplineStore.getState().processTick({
|
||||
rawMana,
|
||||
elements,
|
||||
});
|
||||
rawMana = disciplineResult.rawMana;
|
||||
elements = disciplineResult.elements;
|
||||
|
||||
// Apply per-element regen from discipline effects (regen_{element})
|
||||
for (const [key, value] of Object.entries(disciplineEffects.bonuses)) {
|
||||
if (key.startsWith('regen_') && key !== 'regenBonus') {
|
||||
const element = key.replace('regen_', '');
|
||||
if (elements[element]) {
|
||||
elements[element] = {
|
||||
...elements[element],
|
||||
current: Math.min(
|
||||
elements[element].max,
|
||||
elements[element].current + value * HOURS_PER_TICK,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock enchantment effects from newly unlocked discipline perks
|
||||
if (disciplineResult.unlockedEffects.length > 0) {
|
||||
useCraftingStore.getState().unlockEffects(disciplineResult.unlockedEffects);
|
||||
for (const effectId of disciplineResult.unlockedEffects) {
|
||||
addLog(`✨ Discipline insight unlocked: ${effectId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Combat — delegate to combatStore
|
||||
if (ctx.combat.currentAction === 'climb') {
|
||||
const combatResult = useCombatStore.getState().processCombatTick(
|
||||
rawMana,
|
||||
elements,
|
||||
maxMana,
|
||||
1,
|
||||
(floor, wasGuardian) => {
|
||||
if (wasGuardian) {
|
||||
const defeatedGuardian = getGuardianForFloor(floor);
|
||||
addLog(`\u2694\ufe0f ${defeatedGuardian?.name || 'Guardian'} defeated! Visit the Grimoire to sign a pact.`);
|
||||
} else if (floor % 5 === 0) {
|
||||
addLog(`🏰 Floor ${floor} cleared!`);
|
||||
}
|
||||
},
|
||||
(damage) => {
|
||||
let dmg = damage;
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.floorMaxHP < 0.25) {
|
||||
dmg *= 2;
|
||||
}
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
|
||||
dmg *= 1.5;
|
||||
}
|
||||
return { rawMana, elements, modifiedDamage: dmg };
|
||||
},
|
||||
ctx.prestige.signedPacts,
|
||||
const maxMana = computeMaxMana(
|
||||
{ skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {} },
|
||||
undefined,
|
||||
disciplineEffects,
|
||||
);
|
||||
const baseRegen = computeRegen(
|
||||
{ skills: {}, prestigeUpgrades: ctx.prestige.prestigeUpgrades, skillUpgrades: {}, skillTiers: {}, attunements: {} },
|
||||
undefined,
|
||||
disciplineEffects,
|
||||
);
|
||||
|
||||
rawMana = combatResult.rawMana;
|
||||
elements = combatResult.elements;
|
||||
totalManaGathered += combatResult.totalManaGathered || 0;
|
||||
|
||||
if (combatResult.logMessages) {
|
||||
combatResult.logMessages.forEach(msg => addLog(msg));
|
||||
// Time progression
|
||||
let hour = ctx.game.hour + HOURS_PER_TICK;
|
||||
let day = ctx.game.day;
|
||||
if (hour >= 24) {
|
||||
hour -= 24;
|
||||
day += 1;
|
||||
}
|
||||
|
||||
writes.combat = {
|
||||
...(writes.combat || {}),
|
||||
currentFloor: combatResult.currentFloor,
|
||||
floorHP: combatResult.floorHP,
|
||||
floorMaxHP: combatResult.floorMaxHP,
|
||||
maxFloorReached: combatResult.maxFloorReached,
|
||||
castProgress: combatResult.castProgress,
|
||||
equipmentSpellStates: combatResult.equipmentSpellStates,
|
||||
// Shared insight params — reused for both loop-end and victory
|
||||
const insightParams = {
|
||||
maxFloorReached: ctx.combat.maxFloorReached,
|
||||
totalManaGathered: ctx.mana.totalManaGathered,
|
||||
signedPacts: ctx.prestige.signedPacts,
|
||||
prestigeUpgrades: ctx.prestige.prestigeUpgrades,
|
||||
skills: {} as Record<string, number>,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Phase 3: Write — batch all state updates ─────────────────────────
|
||||
writes.game = { day, hour, incursionStrength };
|
||||
writes.mana = {
|
||||
rawMana,
|
||||
meditateTicks,
|
||||
totalManaGathered,
|
||||
elements,
|
||||
};
|
||||
// Check for loop end
|
||||
if (day > MAX_DAY) {
|
||||
const insightGained = calcInsight(insightParams, disciplineEffects);
|
||||
|
||||
applyTickWrites(writes, {
|
||||
setGame: set,
|
||||
setUI: (w) => useUIStore.setState(w),
|
||||
setPrestige: (w) => usePrestigeStore.setState(w),
|
||||
setMana: (w) => useManaStore.setState(w),
|
||||
setCombat: (w) => useCombatStore.setState(w),
|
||||
setCrafting: (w) => useCraftingStore.setState(w),
|
||||
setAttunement: (w) => useAttunementStore.setState(w),
|
||||
setDiscipline: (w) => useDisciplineStore.setState(w),
|
||||
addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)),
|
||||
});
|
||||
addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`);
|
||||
writes.ui = { ...(writes.ui || {}), gameOver: true, victory: false };
|
||||
writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained };
|
||||
writes.game = { day, hour };
|
||||
applyTickWrites(writes, storeSetters);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for victory (3× insight multiplier)
|
||||
if (ctx.combat.maxFloorReached >= 100 && ctx.prestige.signedPacts.includes(100)) {
|
||||
const insightGained = calcInsight(insightParams, disciplineEffects) * 3;
|
||||
|
||||
addLog(`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`);
|
||||
writes.ui = { ...(writes.ui || {}), gameOver: true, victory: true };
|
||||
writes.prestige = { ...(writes.prestige || {}), loopInsight: insightGained };
|
||||
applyTickWrites(writes, storeSetters);
|
||||
return;
|
||||
}
|
||||
|
||||
// Incursion
|
||||
const incursionStrength = getIncursionStrength(day, hour);
|
||||
|
||||
// Meditation bonus tracking
|
||||
let meditateTicks = ctx.mana.meditateTicks;
|
||||
let meditationMultiplier = 1;
|
||||
|
||||
if (ctx.combat.currentAction === 'meditate') {
|
||||
meditateTicks++;
|
||||
meditationMultiplier = getMeditationBonus(meditateTicks, {}, 1);
|
||||
} else {
|
||||
meditateTicks = 0;
|
||||
}
|
||||
|
||||
// Calculate total attunement conversion per tick
|
||||
let totalConversionPerTick = 0;
|
||||
Object.entries(ctx.attunement.attunements).forEach(([id, state]) => {
|
||||
if (!state.active) return;
|
||||
const def = ATTUNEMENTS_DEF[id];
|
||||
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
|
||||
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
||||
totalConversionPerTick += scaledRate * HOURS_PER_TICK;
|
||||
});
|
||||
|
||||
// Calculate effective regen
|
||||
const effectiveRegen = Math.max(0, baseRegen * (1 - incursionStrength) * meditationMultiplier - totalConversionPerTick);
|
||||
|
||||
// Mana regeneration
|
||||
let rawMana = Math.min(ctx.mana.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
|
||||
let elements = { ...ctx.mana.elements };
|
||||
|
||||
// Apply attunement conversion
|
||||
Object.entries(ctx.attunement.attunements).forEach(([id, state]) => {
|
||||
if (!state.active) return;
|
||||
const def = ATTUNEMENTS_DEF[id];
|
||||
if (!def || def.conversionRate <= 0 || !def.primaryManaType) return;
|
||||
const scaledRate = getAttunementConversionRate(id, state.level || 1);
|
||||
const conversionThisTick = scaledRate * HOURS_PER_TICK;
|
||||
if (elements[def.primaryManaType]) {
|
||||
elements[def.primaryManaType].current = Math.min(
|
||||
elements[def.primaryManaType].max,
|
||||
elements[def.primaryManaType].current + conversionThisTick
|
||||
);
|
||||
}
|
||||
});
|
||||
let totalManaGathered = ctx.mana.totalManaGathered;
|
||||
|
||||
// Convert action — delegate to manaStore
|
||||
if (ctx.combat.currentAction === 'convert') {
|
||||
const convertResult = useManaStore.getState().processConvertAction(rawMana);
|
||||
if (convertResult) {
|
||||
rawMana = convertResult.rawMana;
|
||||
elements = convertResult.elements;
|
||||
}
|
||||
}
|
||||
|
||||
// Pact ritual progress
|
||||
if (ctx.prestige.pactRitualFloor !== null) {
|
||||
const guardian = getGuardianForFloor(ctx.prestige.pactRitualFloor);
|
||||
if (guardian) {
|
||||
const pactAffinityBonus = 1 - (ctx.prestige.prestigeUpgrades.pactAffinity || 0) * 0.1;
|
||||
const requiredTime = guardian.pactTime * pactAffinityBonus;
|
||||
const newProgress = ctx.prestige.pactRitualProgress + HOURS_PER_TICK;
|
||||
|
||||
if (newProgress >= requiredTime) {
|
||||
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
|
||||
writes.prestige = {
|
||||
...(writes.prestige || {}),
|
||||
signedPacts: [...ctx.prestige.signedPacts, ctx.prestige.pactRitualFloor],
|
||||
defeatedGuardians: ctx.prestige.defeatedGuardians.filter(f => f !== ctx.prestige.pactRitualFloor),
|
||||
pactRitualFloor: null,
|
||||
pactRitualProgress: 0,
|
||||
};
|
||||
} else {
|
||||
writes.prestige = {
|
||||
...(writes.prestige || {}),
|
||||
pactRitualProgress: newProgress,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discipline tick — process active disciplines (XP accrual + mana drain)
|
||||
const disciplineResult = useDisciplineStore.getState().processTick({
|
||||
rawMana,
|
||||
elements,
|
||||
});
|
||||
rawMana = disciplineResult.rawMana;
|
||||
elements = disciplineResult.elements;
|
||||
|
||||
// Apply per-element regen from discipline effects (regen_{element})
|
||||
for (const [key, value] of Object.entries(disciplineEffects.bonuses)) {
|
||||
if (key.startsWith('regen_') && key !== 'regenBonus') {
|
||||
const element = key.replace('regen_', '');
|
||||
if (elements[element]) {
|
||||
elements[element] = {
|
||||
...elements[element],
|
||||
current: Math.min(
|
||||
elements[element].max,
|
||||
elements[element].current + value * HOURS_PER_TICK,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock enchantment effects from newly unlocked discipline perks
|
||||
if (disciplineResult.unlockedEffects.length > 0) {
|
||||
useCraftingStore.getState().unlockEffects(disciplineResult.unlockedEffects);
|
||||
for (const effectId of disciplineResult.unlockedEffects) {
|
||||
addLog(`✨ Discipline insight unlocked: ${effectId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Combat — delegate to combatStore
|
||||
if (ctx.combat.currentAction === 'climb') {
|
||||
const combatResult = useCombatStore.getState().processCombatTick(
|
||||
rawMana,
|
||||
elements,
|
||||
maxMana,
|
||||
1,
|
||||
(floor, wasGuardian) => {
|
||||
if (wasGuardian) {
|
||||
const defeatedGuardian = getGuardianForFloor(floor);
|
||||
addLog(`\u2694\ufe0f ${defeatedGuardian?.name || 'Guardian'} defeated! Visit the Grimoire to sign a pact.`);
|
||||
} else if (floor % 5 === 0) {
|
||||
addLog(`🏰 Floor ${floor} cleared!`);
|
||||
}
|
||||
},
|
||||
(damage) => {
|
||||
let dmg = damage;
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && ctx.combat.floorHP / ctx.combat.floorMaxHP < 0.25) {
|
||||
dmg *= 2;
|
||||
}
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
|
||||
dmg *= 1.5;
|
||||
}
|
||||
return { rawMana, elements, modifiedDamage: dmg };
|
||||
},
|
||||
ctx.prestige.signedPacts,
|
||||
);
|
||||
|
||||
rawMana = combatResult.rawMana;
|
||||
elements = combatResult.elements;
|
||||
totalManaGathered += combatResult.totalManaGathered || 0;
|
||||
|
||||
if (combatResult.logMessages) {
|
||||
combatResult.logMessages.forEach(msg => addLog(msg));
|
||||
}
|
||||
|
||||
writes.combat = {
|
||||
...(writes.combat || {}),
|
||||
currentFloor: combatResult.currentFloor,
|
||||
floorHP: combatResult.floorHP,
|
||||
floorMaxHP: combatResult.floorMaxHP,
|
||||
maxFloorReached: combatResult.maxFloorReached,
|
||||
castProgress: combatResult.castProgress,
|
||||
equipmentSpellStates: combatResult.equipmentSpellStates,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Phase 3: Write — batch all state updates ─────────────────────────
|
||||
writes.game = { day, hour, incursionStrength };
|
||||
writes.mana = {
|
||||
rawMana,
|
||||
meditateTicks,
|
||||
totalManaGathered,
|
||||
elements,
|
||||
};
|
||||
|
||||
applyTickWrites(writes, storeSetters);
|
||||
} catch (error: unknown) {
|
||||
// Log error to UI store if available, otherwise console error
|
||||
try {
|
||||
|
||||
@@ -86,7 +86,6 @@ export const useManaStore = create<ManaStore>()(
|
||||
spendRawMana: (amount: number) => {
|
||||
const state = get();
|
||||
if (state.rawMana < amount) return false;
|
||||
|
||||
set({ rawMana: state.rawMana - amount });
|
||||
return true;
|
||||
},
|
||||
@@ -98,71 +97,35 @@ export const useManaStore = create<ManaStore>()(
|
||||
}));
|
||||
},
|
||||
|
||||
setMeditateTicks: (ticks: number) => {
|
||||
set({ meditateTicks: ticks });
|
||||
},
|
||||
|
||||
incrementMeditateTicks: () => {
|
||||
set((state) => ({ meditateTicks: state.meditateTicks + 1 }));
|
||||
},
|
||||
|
||||
resetMeditateTicks: () => {
|
||||
set({ meditateTicks: 0 });
|
||||
},
|
||||
setMeditateTicks: (ticks: number) => set({ meditateTicks: ticks }),
|
||||
incrementMeditateTicks: () => set((s) => ({ meditateTicks: s.meditateTicks + 1 })),
|
||||
resetMeditateTicks: () => set({ meditateTicks: 0 }),
|
||||
|
||||
convertMana: (element: string, amount: number) => {
|
||||
const state = get();
|
||||
const elem = state.elements[element];
|
||||
if (!elem?.unlocked) {
|
||||
return fail(ErrorCode.ELEMENT_NOT_UNLOCKED, `Element ${element} is not unlocked`);
|
||||
}
|
||||
if (!elem?.unlocked) return fail(ErrorCode.ELEMENT_NOT_UNLOCKED, `Element ${element} is not unlocked`);
|
||||
|
||||
const cost = MANA_PER_ELEMENT * amount;
|
||||
if (state.rawMana < cost) {
|
||||
return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`);
|
||||
}
|
||||
if (elem.current >= elem.max) {
|
||||
return fail(ErrorCode.ELEMENT_MAX_CAPACITY, `Element ${element} is at max capacity`);
|
||||
}
|
||||
if (state.rawMana < cost) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`);
|
||||
if (elem.current >= elem.max) return fail(ErrorCode.ELEMENT_MAX_CAPACITY, `Element ${element} is at max capacity`);
|
||||
|
||||
const canConvert = Math.min(
|
||||
amount,
|
||||
Math.floor(state.rawMana / MANA_PER_ELEMENT),
|
||||
elem.max - elem.current
|
||||
);
|
||||
|
||||
if (canConvert <= 0) {
|
||||
return fail(ErrorCode.INVALID_INPUT, 'Cannot convert 0 or negative amount');
|
||||
}
|
||||
const canConvert = Math.min(amount, Math.floor(state.rawMana / MANA_PER_ELEMENT), elem.max - elem.current);
|
||||
if (canConvert <= 0) return fail(ErrorCode.INVALID_INPUT, 'Cannot convert 0 or negative amount');
|
||||
|
||||
set({
|
||||
rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT,
|
||||
elements: {
|
||||
...state.elements,
|
||||
[element]: { ...elem, current: elem.current + canConvert },
|
||||
},
|
||||
elements: { ...state.elements, [element]: { ...elem, current: elem.current + canConvert } },
|
||||
});
|
||||
|
||||
return ok({ converted: canConvert });
|
||||
},
|
||||
|
||||
unlockElement: (element: string, cost: number) => {
|
||||
const state = get();
|
||||
if (state.elements[element]?.unlocked) {
|
||||
return fail(ErrorCode.INVALID_INPUT, `Element ${element} is already unlocked`);
|
||||
}
|
||||
if (state.rawMana < cost) {
|
||||
return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`);
|
||||
}
|
||||
|
||||
set({
|
||||
rawMana: state.rawMana - cost,
|
||||
elements: {
|
||||
...state.elements,
|
||||
[element]: { ...state.elements[element], unlocked: true },
|
||||
},
|
||||
});
|
||||
if (state.elements[element]?.unlocked) return fail(ErrorCode.INVALID_INPUT, `Element ${element} is already unlocked`);
|
||||
if (state.rawMana < cost) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`);
|
||||
|
||||
set({ rawMana: state.rawMana - cost, elements: { ...state.elements, [element]: { ...state.elements[element], unlocked: true } } });
|
||||
return okVoid();
|
||||
},
|
||||
|
||||
@@ -170,15 +133,8 @@ export const useManaStore = create<ManaStore>()(
|
||||
set((state) => {
|
||||
const elem = state.elements[element];
|
||||
if (!elem) return state;
|
||||
|
||||
return {
|
||||
elements: {
|
||||
...state.elements,
|
||||
[element]: {
|
||||
...elem,
|
||||
current: Math.min(elem.current + amount, max),
|
||||
},
|
||||
},
|
||||
elements: { ...state.elements, [element]: { ...elem, current: Math.min(elem.current + amount, max) } },
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -186,64 +142,35 @@ export const useManaStore = create<ManaStore>()(
|
||||
spendElementMana: (element: string, amount: number) => {
|
||||
const state = get();
|
||||
const elem = state.elements[element];
|
||||
if (!elem) {
|
||||
return fail(ErrorCode.INVALID_ELEMENT, `Element ${element} does not exist`);
|
||||
}
|
||||
if (elem.current < amount) {
|
||||
return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amount} ${element} mana, have ${elem.current}`);
|
||||
}
|
||||
|
||||
set({
|
||||
elements: {
|
||||
...state.elements,
|
||||
[element]: { ...elem, current: elem.current - amount },
|
||||
},
|
||||
});
|
||||
if (!elem) return fail(ErrorCode.INVALID_ELEMENT, `Element ${element} does not exist`);
|
||||
if (elem.current < amount) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amount} ${element} mana, have ${elem.current}`);
|
||||
|
||||
set({ elements: { ...state.elements, [element]: { ...elem, current: elem.current - amount } } });
|
||||
return okVoid();
|
||||
},
|
||||
|
||||
setElementMax: (max: number) => {
|
||||
set((state) => ({
|
||||
elements: Object.fromEntries(
|
||||
Object.entries(state.elements).map(([k, v]) => [k, { ...v, max }])
|
||||
) as Record<string, ElementState>,
|
||||
elements: Object.fromEntries(Object.entries(state.elements).map(([k, v]) => [k, { ...v, max }])) as Record<string, ElementState>,
|
||||
}));
|
||||
},
|
||||
|
||||
craftComposite: (target: string, recipe: string[]) => {
|
||||
const state = get();
|
||||
|
||||
// Count required ingredients
|
||||
const costs: Record<string, number> = {};
|
||||
recipe.forEach(r => {
|
||||
costs[r] = (costs[r] || 0) + 1;
|
||||
});
|
||||
recipe.forEach(r => { costs[r] = (costs[r] || 0) + 1; });
|
||||
|
||||
// Check if we have all ingredients
|
||||
for (const [r, amt] of Object.entries(costs)) {
|
||||
if ((state.elements[r]?.current || 0) < amt) {
|
||||
return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amt} ${r} mana, have ${state.elements[r]?.current || 0}`);
|
||||
}
|
||||
if ((state.elements[r]?.current || 0) < amt) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amt} ${r} mana, have ${state.elements[r]?.current || 0}`);
|
||||
}
|
||||
|
||||
// Deduct ingredients
|
||||
const newElems = { ...state.elements };
|
||||
for (const [r, amt] of Object.entries(costs)) {
|
||||
newElems[r] = {
|
||||
...newElems[r],
|
||||
current: newElems[r].current - amt,
|
||||
};
|
||||
newElems[r] = { ...newElems[r], current: newElems[r].current - amt };
|
||||
}
|
||||
|
||||
// Add crafted element
|
||||
const targetElem = newElems[target];
|
||||
newElems[target] = {
|
||||
...(targetElem || { current: 0, max: 10, unlocked: false }),
|
||||
current: (targetElem?.current || 0) + 1,
|
||||
unlocked: true,
|
||||
};
|
||||
|
||||
newElems[target] = { ...(targetElem || { current: 0, max: 10, unlocked: false }), current: (targetElem?.current || 0) + 1, unlocked: true };
|
||||
set({ elements: newElems });
|
||||
return okVoid();
|
||||
},
|
||||
@@ -252,27 +179,16 @@ export const useManaStore = create<ManaStore>()(
|
||||
const state = get();
|
||||
const elements = { ...state.elements };
|
||||
|
||||
const unlockedElements = Object.entries(elements)
|
||||
.filter(([, e]) => e.unlocked && e.current < e.max);
|
||||
|
||||
if (unlockedElements.length === 0 || rawMana < 100) return null;
|
||||
const unlockedElements = Object.entries(elements).filter(([, e]) => e.unlocked && e.current < e.max);
|
||||
if (unlockedElements.length === 0 || rawMana < MANA_PER_ELEMENT) return null;
|
||||
|
||||
unlockedElements.sort((a, b) => (b[1].max - b[1].current) - (a[1].max - a[1].current));
|
||||
const [targetId, targetState] = unlockedElements[0];
|
||||
const canConvert = Math.min(
|
||||
Math.floor(rawMana / 100),
|
||||
targetState.max - targetState.current
|
||||
);
|
||||
|
||||
const canConvert = Math.min(Math.floor(rawMana / MANA_PER_ELEMENT), targetState.max - targetState.current);
|
||||
if (canConvert <= 0) return null;
|
||||
|
||||
rawMana -= canConvert * 100;
|
||||
const updatedElements = {
|
||||
...elements,
|
||||
[targetId]: { ...targetState, current: targetState.current + canConvert }
|
||||
};
|
||||
|
||||
return { rawMana, elements: updatedElements };
|
||||
rawMana -= canConvert * MANA_PER_ELEMENT;
|
||||
return { rawMana, elements: { ...elements, [targetId]: { ...targetState, current: targetState.current + canConvert } } };
|
||||
},
|
||||
|
||||
resetMana: (
|
||||
@@ -283,24 +199,13 @@ export const useManaStore = create<ManaStore>()(
|
||||
) => {
|
||||
const elementMax = 10 + (prestigeUpgrades.elemMax || 0) * 5;
|
||||
const startingMana = 10 + (prestigeUpgrades.manaStart || 0) * 10;
|
||||
const elements = makeInitialElements(elementMax, prestigeUpgrades);
|
||||
|
||||
set({
|
||||
rawMana: startingMana,
|
||||
meditateTicks: 0,
|
||||
totalManaGathered: 0,
|
||||
elements,
|
||||
});
|
||||
set({ rawMana: startingMana, meditateTicks: 0, totalManaGathered: 0, elements: makeInitialElements(elementMax, prestigeUpgrades) });
|
||||
},
|
||||
}),
|
||||
{
|
||||
storage: createSafeStorage(),
|
||||
name: 'mana-loop-mana',
|
||||
partialize: (state) => ({
|
||||
rawMana: state.rawMana,
|
||||
totalManaGathered: state.totalManaGathered,
|
||||
elements: state.elements,
|
||||
}),
|
||||
partialize: (state) => ({ rawMana: state.rawMana, totalManaGathered: state.totalManaGathered, elements: state.elements }),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -311,16 +216,10 @@ export function makeInitialElements(
|
||||
prestigeUpgrades: Record<string, number> = {}
|
||||
): Record<string, ElementState> {
|
||||
const elemStart = (prestigeUpgrades.elemStart || 0) * 5;
|
||||
|
||||
const elements: Record<string, ElementState> = {};
|
||||
Object.keys(ELEMENTS).forEach(k => {
|
||||
for (const k of Object.keys(ELEMENTS)) {
|
||||
const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k);
|
||||
elements[k] = {
|
||||
current: isUnlocked ? elemStart : 0,
|
||||
max: elementMax,
|
||||
unlocked: isUnlocked,
|
||||
};
|
||||
});
|
||||
|
||||
elements[k] = { current: isUnlocked ? elemStart : 0, max: elementMax, unlocked: isUnlocked };
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user