Initial commit
This commit is contained in:
Executable
+157
@@ -0,0 +1,157 @@
|
||||
// ─── Combat Slice ─────────────────────────────────────────────────────────────
|
||||
// Manages spire climbing, combat, and floor progression
|
||||
|
||||
import type { StateCreator } from 'zustand';
|
||||
import type { GameState, GameAction, SpellCost } from '../types';
|
||||
import { GUARDIANS, SPELLS_DEF, ELEMENTS, ELEMENT_OPPOSITES } from '../constants';
|
||||
import { getFloorMaxHP, getFloorElement, calcDamage, computePactMultiplier, canAffordSpellCost, deductSpellCost } from './computed';
|
||||
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '../upgrade-effects';
|
||||
|
||||
export interface CombatSlice {
|
||||
// State
|
||||
currentFloor: number;
|
||||
floorHP: number;
|
||||
floorMaxHP: number;
|
||||
maxFloorReached: number;
|
||||
activeSpell: string;
|
||||
currentAction: GameAction;
|
||||
castProgress: number;
|
||||
|
||||
// Actions
|
||||
setAction: (action: GameAction) => void;
|
||||
setSpell: (spellId: string) => void;
|
||||
getDamage: (spellId: string) => number;
|
||||
|
||||
// Internal combat processing
|
||||
processCombat: (deltaHours: number) => Partial<GameState>;
|
||||
}
|
||||
|
||||
export const createCombatSlice = (
|
||||
set: StateCreator<GameState>['set'],
|
||||
get: () => GameState
|
||||
): CombatSlice => ({
|
||||
currentFloor: 1,
|
||||
floorHP: getFloorMaxHP(1),
|
||||
floorMaxHP: getFloorMaxHP(1),
|
||||
maxFloorReached: 1,
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'meditate',
|
||||
castProgress: 0,
|
||||
|
||||
setAction: (action: GameAction) => {
|
||||
set((state) => ({
|
||||
currentAction: action,
|
||||
meditateTicks: action === 'meditate' ? state.meditateTicks : 0,
|
||||
}));
|
||||
},
|
||||
|
||||
setSpell: (spellId: string) => {
|
||||
const state = get();
|
||||
if (state.spells[spellId]?.learned) {
|
||||
set({ activeSpell: spellId });
|
||||
}
|
||||
},
|
||||
|
||||
getDamage: (spellId: string) => {
|
||||
const state = get();
|
||||
const floorElem = getFloorElement(state.currentFloor);
|
||||
return calcDamage(state, spellId, floorElem);
|
||||
},
|
||||
|
||||
processCombat: (deltaHours: number) => {
|
||||
const state = get();
|
||||
if (state.currentAction !== 'climb') return {};
|
||||
|
||||
const spellId = state.activeSpell;
|
||||
const spellDef = SPELLS_DEF[spellId];
|
||||
if (!spellDef) return {};
|
||||
|
||||
const effects = computeEffects(state.skillUpgrades || {}, state.skillTiers || {});
|
||||
const baseAttackSpeed = 1 + (state.skills.quickCast || 0) * 0.05;
|
||||
const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier;
|
||||
const spellCastSpeed = spellDef.castSpeed || 1;
|
||||
const progressPerTick = deltaHours * spellCastSpeed * totalAttackSpeed;
|
||||
|
||||
let castProgress = (state.castProgress || 0) + progressPerTick;
|
||||
let rawMana = state.rawMana;
|
||||
let elements = state.elements;
|
||||
let totalManaGathered = state.totalManaGathered;
|
||||
let currentFloor = state.currentFloor;
|
||||
let floorHP = state.floorHP;
|
||||
let floorMaxHP = state.floorMaxHP;
|
||||
let maxFloorReached = state.maxFloorReached;
|
||||
let signedPacts = state.signedPacts;
|
||||
let pendingPactOffer = state.pendingPactOffer;
|
||||
const log = [...state.log];
|
||||
const skills = state.skills;
|
||||
|
||||
const floorElement = getFloorElement(currentFloor);
|
||||
|
||||
while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements)) {
|
||||
// Deduct cost
|
||||
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
|
||||
rawMana = afterCost.rawMana;
|
||||
elements = afterCost.elements;
|
||||
totalManaGathered += spellDef.cost.amount;
|
||||
|
||||
// Calculate damage
|
||||
let dmg = calcDamage(state, spellId, floorElement);
|
||||
dmg = dmg * effects.baseDamageMultiplier + effects.baseDamageBonus;
|
||||
|
||||
// Executioner: +100% damage to enemies below 25% HP
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.EXECUTIONER) && floorHP / floorMaxHP < 0.25) {
|
||||
dmg *= 2;
|
||||
}
|
||||
|
||||
// Berserker: +50% damage when below 50% mana
|
||||
const maxMana = 100; // Would need proper max mana calculation
|
||||
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
|
||||
dmg *= 1.5;
|
||||
}
|
||||
|
||||
// Spell echo - chance to cast again
|
||||
const echoChance = (skills.spellEcho || 0) * 0.1;
|
||||
if (Math.random() < echoChance) {
|
||||
dmg *= 2;
|
||||
log.unshift('✨ Spell Echo! Double damage!');
|
||||
}
|
||||
|
||||
// Apply damage
|
||||
floorHP = Math.max(0, floorHP - dmg);
|
||||
castProgress -= 1;
|
||||
|
||||
if (floorHP <= 0) {
|
||||
const wasGuardian = GUARDIANS[currentFloor];
|
||||
if (wasGuardian && !signedPacts.includes(currentFloor)) {
|
||||
pendingPactOffer = currentFloor;
|
||||
log.unshift(`⚔️ ${wasGuardian.name} defeated! They offer a pact...`);
|
||||
} else if (!wasGuardian) {
|
||||
if (currentFloor % 5 === 0) {
|
||||
log.unshift(`🏰 Floor ${currentFloor} cleared!`);
|
||||
}
|
||||
}
|
||||
|
||||
currentFloor = currentFloor + 1;
|
||||
if (currentFloor > 100) currentFloor = 100;
|
||||
floorMaxHP = getFloorMaxHP(currentFloor);
|
||||
floorHP = floorMaxHP;
|
||||
maxFloorReached = Math.max(maxFloorReached, currentFloor);
|
||||
castProgress = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rawMana,
|
||||
elements,
|
||||
totalManaGathered,
|
||||
currentFloor,
|
||||
floorHP,
|
||||
floorMaxHP,
|
||||
maxFloorReached,
|
||||
signedPacts,
|
||||
pendingPactOffer,
|
||||
castProgress,
|
||||
log,
|
||||
};
|
||||
},
|
||||
});
|
||||
Executable
+322
@@ -0,0 +1,322 @@
|
||||
// ─── Computed Stats Functions ─────────────────────────────────────────────────
|
||||
|
||||
import type { GameState } from '../types';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, PRESTIGE_DEF, ELEMENT_OPPOSITES } from '../constants';
|
||||
import { computeEffects } from '../upgrade-effects';
|
||||
import { getTierMultiplier } from '../skill-evolution';
|
||||
|
||||
// Helper to get effective skill level accounting for tiers
|
||||
export function getEffectiveSkillLevel(
|
||||
skills: Record<string, number>,
|
||||
baseSkillId: string,
|
||||
skillTiers: Record<string, number> = {}
|
||||
): { level: number; tier: number; tierMultiplier: number } {
|
||||
const currentTier = skillTiers[baseSkillId] || 1;
|
||||
const tieredSkillId = currentTier > 1 ? `${baseSkillId}_t${currentTier}` : baseSkillId;
|
||||
const level = skills[tieredSkillId] || skills[baseSkillId] || 0;
|
||||
const tierMultiplier = Math.pow(10, currentTier - 1);
|
||||
return { level, tier: currentTier, tierMultiplier };
|
||||
}
|
||||
|
||||
export function computeMaxMana(
|
||||
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
|
||||
effects?: ReturnType<typeof computeEffects>
|
||||
): number {
|
||||
const pu = state.prestigeUpgrades;
|
||||
const skillTiers = state.skillTiers || {};
|
||||
const skillUpgrades = state.skillUpgrades || {};
|
||||
|
||||
const manaWellLevel = getEffectiveSkillLevel(state.skills, 'manaWell', skillTiers);
|
||||
|
||||
const base =
|
||||
100 +
|
||||
manaWellLevel.level * 100 * manaWellLevel.tierMultiplier +
|
||||
(pu.manaWell || 0) * 500;
|
||||
|
||||
const computedEffects = effects ?? computeEffects(skillUpgrades, skillTiers);
|
||||
return Math.floor((base + computedEffects.maxManaBonus) * computedEffects.maxManaMultiplier);
|
||||
}
|
||||
|
||||
export function computeElementMax(
|
||||
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
|
||||
effects?: ReturnType<typeof computeEffects>
|
||||
): number {
|
||||
const pu = state.prestigeUpgrades;
|
||||
const skillTiers = state.skillTiers || {};
|
||||
const skillUpgrades = state.skillUpgrades || {};
|
||||
|
||||
const elemAttuneLevel = getEffectiveSkillLevel(state.skills, 'elemAttune', skillTiers);
|
||||
const base = 10 + elemAttuneLevel.level * 50 * elemAttuneLevel.tierMultiplier + (pu.elementalAttune || 0) * 25;
|
||||
|
||||
const computedEffects = effects ?? computeEffects(skillUpgrades, skillTiers);
|
||||
return Math.floor((base + computedEffects.elementCapBonus) * computedEffects.elementCapMultiplier);
|
||||
}
|
||||
|
||||
export function computeRegen(
|
||||
state: Pick<GameState, 'skills' | 'prestigeUpgrades' | 'skillUpgrades' | 'skillTiers'>,
|
||||
effects?: ReturnType<typeof computeEffects>
|
||||
): number {
|
||||
const pu = state.prestigeUpgrades;
|
||||
const skillTiers = state.skillTiers || {};
|
||||
const skillUpgrades = state.skillUpgrades || {};
|
||||
|
||||
const temporalBonus = 1 + (pu.temporalEcho || 0) * 0.1;
|
||||
|
||||
const manaFlowLevel = getEffectiveSkillLevel(state.skills, 'manaFlow', skillTiers);
|
||||
const manaSpringLevel = getEffectiveSkillLevel(state.skills, 'manaSpring', skillTiers);
|
||||
|
||||
const base =
|
||||
2 +
|
||||
manaFlowLevel.level * 1 * manaFlowLevel.tierMultiplier +
|
||||
manaSpringLevel.level * 2 * manaSpringLevel.tierMultiplier +
|
||||
(pu.manaFlow || 0) * 0.5;
|
||||
|
||||
let regen = base * temporalBonus;
|
||||
const computedEffects = effects ?? computeEffects(skillUpgrades, skillTiers);
|
||||
regen = (regen + computedEffects.regenBonus + computedEffects.permanentRegenBonus) * computedEffects.regenMultiplier;
|
||||
|
||||
return regen;
|
||||
}
|
||||
|
||||
export function computeClickMana(
|
||||
state: Pick<GameState, 'skills' | 'skillUpgrades' | 'skillTiers'>,
|
||||
effects?: ReturnType<typeof computeEffects>
|
||||
): number {
|
||||
const skillTiers = state.skillTiers || {};
|
||||
const skillUpgrades = state.skillUpgrades || {};
|
||||
|
||||
const manaTapLevel = getEffectiveSkillLevel(state.skills, 'manaTap', skillTiers);
|
||||
const manaSurgeLevel = getEffectiveSkillLevel(state.skills, 'manaSurge', skillTiers);
|
||||
|
||||
const base =
|
||||
1 +
|
||||
manaTapLevel.level * 1 * manaTapLevel.tierMultiplier +
|
||||
manaSurgeLevel.level * 3 * manaSurgeLevel.tierMultiplier;
|
||||
|
||||
const computedEffects = effects ?? computeEffects(skillUpgrades, skillTiers);
|
||||
return Math.floor((base + computedEffects.clickManaBonus) * computedEffects.clickManaMultiplier);
|
||||
}
|
||||
|
||||
// Elemental damage bonus
|
||||
export function getElementalBonus(spellElem: string, floorElem: string): number {
|
||||
if (spellElem === 'raw') return 1.0;
|
||||
|
||||
if (spellElem === floorElem) return 1.25; // Same element: +25% damage
|
||||
|
||||
if (ELEMENT_OPPOSITES[floorElem] === spellElem) return 1.5; // Super effective
|
||||
if (ELEMENT_OPPOSITES[spellElem] === floorElem) return 0.75; // Weak
|
||||
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Compute the pact multiplier with interference/synergy system
|
||||
export function computePactMultiplier(
|
||||
state: Pick<GameState, 'signedPacts' | 'pactInterferenceMitigation' | 'signedPactDetails'>
|
||||
): number {
|
||||
const { signedPacts, pactInterferenceMitigation = 0 } = state;
|
||||
|
||||
if (signedPacts.length === 0) return 1.0;
|
||||
|
||||
let baseMult = 1.0;
|
||||
for (const floor of signedPacts) {
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (guardian) {
|
||||
baseMult *= guardian.damageMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
if (signedPacts.length === 1) return baseMult;
|
||||
|
||||
const numAdditionalPacts = signedPacts.length - 1;
|
||||
const basePenalty = 0.5 * numAdditionalPacts;
|
||||
const mitigationReduction = Math.min(pactInterferenceMitigation, 5) * 0.1;
|
||||
const effectivePenalty = Math.max(0, basePenalty - mitigationReduction);
|
||||
|
||||
if (pactInterferenceMitigation >= 5) {
|
||||
const synergyBonus = (pactInterferenceMitigation - 5) * 0.1;
|
||||
return baseMult * (1 + synergyBonus);
|
||||
}
|
||||
|
||||
return baseMult * (1 - effectivePenalty);
|
||||
}
|
||||
|
||||
// Compute the insight multiplier from signed pacts
|
||||
export function computePactInsightMultiplier(
|
||||
state: Pick<GameState, 'signedPacts' | 'pactInterferenceMitigation'>
|
||||
): number {
|
||||
const { signedPacts, pactInterferenceMitigation = 0 } = state;
|
||||
|
||||
if (signedPacts.length === 0) return 1.0;
|
||||
|
||||
let mult = 1.0;
|
||||
for (const floor of signedPacts) {
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (guardian) {
|
||||
mult *= guardian.insightMultiplier;
|
||||
}
|
||||
}
|
||||
|
||||
if (signedPacts.length > 1) {
|
||||
const numAdditionalPacts = signedPacts.length - 1;
|
||||
const basePenalty = 0.5 * numAdditionalPacts;
|
||||
const mitigationReduction = Math.min(pactInterferenceMitigation, 5) * 0.1;
|
||||
const effectivePenalty = Math.max(0, basePenalty - mitigationReduction);
|
||||
|
||||
if (pactInterferenceMitigation >= 5) {
|
||||
const synergyBonus = (pactInterferenceMitigation - 5) * 0.1;
|
||||
return mult * (1 + synergyBonus);
|
||||
}
|
||||
|
||||
return mult * (1 - effectivePenalty);
|
||||
}
|
||||
|
||||
return mult;
|
||||
}
|
||||
|
||||
export function calcDamage(
|
||||
state: Pick<GameState, 'skills' | 'signedPacts' | 'pactInterferenceMitigation' | 'signedPactDetails' | 'skillUpgrades' | 'skillTiers'>,
|
||||
spellId: string,
|
||||
floorElem?: string,
|
||||
effects?: ReturnType<typeof computeEffects>
|
||||
): number {
|
||||
const sp = SPELLS_DEF[spellId];
|
||||
if (!sp) return 5;
|
||||
|
||||
const skillTiers = state.skillTiers || {};
|
||||
const skillUpgrades = state.skillUpgrades || {};
|
||||
const computedEffects = effects ?? computeEffects(skillUpgrades, skillTiers);
|
||||
|
||||
// Get effective skill levels with tier multipliers
|
||||
const combatTrainLevel = getEffectiveSkillLevel(state.skills, 'combatTrain', skillTiers);
|
||||
const arcaneFuryLevel = getEffectiveSkillLevel(state.skills, 'arcaneFury', skillTiers);
|
||||
const elemMasteryLevel = getEffectiveSkillLevel(state.skills, 'elementalMastery', skillTiers);
|
||||
const guardianBaneLevel = getEffectiveSkillLevel(state.skills, 'guardianBane', skillTiers);
|
||||
const precisionLevel = getEffectiveSkillLevel(state.skills, 'precision', skillTiers);
|
||||
|
||||
// Base damage from spell + combat training
|
||||
const baseDmg = sp.dmg + combatTrainLevel.level * 5 * combatTrainLevel.tierMultiplier;
|
||||
|
||||
// Spell damage multiplier from arcane fury
|
||||
const pct = 1 + arcaneFuryLevel.level * 0.1 * arcaneFuryLevel.tierMultiplier;
|
||||
|
||||
// Elemental mastery bonus
|
||||
const elemMasteryBonus = 1 + elemMasteryLevel.level * 0.15 * elemMasteryLevel.tierMultiplier;
|
||||
|
||||
// Guardian bane bonus (only for guardian floors)
|
||||
const guardianBonus = floorElem && Object.values(GUARDIANS).find(g => g.element === floorElem)
|
||||
? 1 + guardianBaneLevel.level * 0.2 * guardianBaneLevel.tierMultiplier
|
||||
: 1;
|
||||
|
||||
// Crit chance from precision
|
||||
const skillCritChance = precisionLevel.level * 0.05 * precisionLevel.tierMultiplier;
|
||||
const totalCritChance = skillCritChance + computedEffects.critChanceBonus;
|
||||
|
||||
// Pact multiplier
|
||||
const pactMult = computePactMultiplier(state);
|
||||
|
||||
// Calculate base damage
|
||||
let damage = baseDmg * pct * pactMult * elemMasteryBonus * guardianBonus;
|
||||
|
||||
// Apply upgrade effects: base damage multiplier and bonus
|
||||
damage = damage * computedEffects.baseDamageMultiplier + computedEffects.baseDamageBonus;
|
||||
|
||||
// Apply elemental damage multiplier from upgrades
|
||||
damage *= computedEffects.elementalDamageMultiplier;
|
||||
|
||||
// Apply elemental bonus for floor
|
||||
if (floorElem) {
|
||||
damage *= getElementalBonus(sp.elem, floorElem);
|
||||
}
|
||||
|
||||
// Apply critical hit
|
||||
if (Math.random() < totalCritChance) {
|
||||
damage *= computedEffects.critDamageMultiplier;
|
||||
}
|
||||
|
||||
return damage;
|
||||
}
|
||||
|
||||
export function calcInsight(state: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'prestigeUpgrades' | 'skills'>): number {
|
||||
const pu = state.prestigeUpgrades;
|
||||
const skillBonus = 1 + (state.skills.insightHarvest || 0) * 0.1;
|
||||
const mult = (1 + (pu.insightAmp || 0) * 0.25) * skillBonus;
|
||||
return Math.floor(
|
||||
(state.maxFloorReached * 15 + state.totalManaGathered / 500 + state.signedPacts.length * 150) * mult
|
||||
);
|
||||
}
|
||||
|
||||
export function getMeditationBonus(meditateTicks: number, skills: Record<string, number>, meditationEfficiency: number = 1): number {
|
||||
const hasMeditation = skills.meditation === 1;
|
||||
const hasDeepTrance = skills.deepTrance === 1;
|
||||
const hasVoidMeditation = skills.voidMeditation === 1;
|
||||
|
||||
const hours = meditateTicks * 0.04; // HOURS_PER_TICK
|
||||
|
||||
let bonus = 1 + Math.min(hours / 4, 0.5);
|
||||
|
||||
if (hasMeditation && hours >= 4) {
|
||||
bonus = 2.5;
|
||||
}
|
||||
|
||||
if (hasDeepTrance && hours >= 6) {
|
||||
bonus = 3.0;
|
||||
}
|
||||
|
||||
if (hasVoidMeditation && hours >= 8) {
|
||||
bonus = 5.0;
|
||||
}
|
||||
|
||||
bonus *= meditationEfficiency;
|
||||
|
||||
return bonus;
|
||||
}
|
||||
|
||||
export function getIncursionStrength(day: number, hour: number): number {
|
||||
const INCURSION_START_DAY = 20;
|
||||
const MAX_DAY = 30;
|
||||
|
||||
if (day < INCURSION_START_DAY) return 0;
|
||||
const totalHours = (day - INCURSION_START_DAY) * 24 + hour;
|
||||
const maxHours = (MAX_DAY - INCURSION_START_DAY) * 24;
|
||||
return Math.min(0.95, (totalHours / maxHours) * 0.95);
|
||||
}
|
||||
|
||||
export function getFloorMaxHP(floor: number): number {
|
||||
if (GUARDIANS[floor]) return GUARDIANS[floor].hp;
|
||||
const baseHP = 100;
|
||||
const floorScaling = floor * 50;
|
||||
const exponentialScaling = Math.pow(floor, 1.7);
|
||||
return Math.floor(baseHP + floorScaling + exponentialScaling);
|
||||
}
|
||||
|
||||
export function getFloorElement(floor: number): string {
|
||||
const FLOOR_ELEM_CYCLE = ["fire", "water", "air", "earth", "light", "dark", "life", "death"];
|
||||
return FLOOR_ELEM_CYCLE[(floor - 1) % 8];
|
||||
}
|
||||
|
||||
// Formatting utilities
|
||||
export function fmt(n: number): string {
|
||||
if (!isFinite(n) || isNaN(n)) return '0';
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
|
||||
return Math.floor(n).toString();
|
||||
}
|
||||
|
||||
export function fmtDec(n: number, d: number = 1): string {
|
||||
return isFinite(n) ? n.toFixed(d) : '0';
|
||||
}
|
||||
|
||||
// Check if player can afford spell cost
|
||||
export function canAffordSpellCost(
|
||||
cost: { type: 'raw' | 'element'; element?: string; amount: number },
|
||||
rawMana: number,
|
||||
elements: Record<string, { current: number; max: number; unlocked: boolean }>
|
||||
): boolean {
|
||||
if (cost.type === 'raw') {
|
||||
return rawMana >= cost.amount;
|
||||
} else {
|
||||
const elem = elements[cost.element || ''];
|
||||
return elem && elem.unlocked && elem.current >= cost.amount;
|
||||
}
|
||||
}
|
||||
Executable
+644
@@ -0,0 +1,644 @@
|
||||
// ─── Crafting Store Slice ────────────────────────────────────────────────────────
|
||||
// Handles equipment, enchantments, and crafting progress
|
||||
|
||||
import type {
|
||||
EquipmentInstance,
|
||||
AppliedEnchantment,
|
||||
EnchantmentDesign,
|
||||
DesignEffect,
|
||||
DesignProgress,
|
||||
PreparationProgress,
|
||||
ApplicationProgress,
|
||||
EquipmentSpellState
|
||||
} from '../types';
|
||||
import {
|
||||
EQUIPMENT_TYPES,
|
||||
EQUIPMENT_SLOTS,
|
||||
type EquipmentSlot,
|
||||
type EquipmentTypeDef,
|
||||
getEquipmentType,
|
||||
calculateRarity
|
||||
} from '../data/equipment';
|
||||
import {
|
||||
ENCHANTMENT_EFFECTS,
|
||||
getEnchantmentEffect,
|
||||
canApplyEffect,
|
||||
calculateEffectCapacityCost,
|
||||
type EnchantmentEffectDef
|
||||
} from '../data/enchantment-effects';
|
||||
import { SPELLS_DEF } from '../constants';
|
||||
import type { StateCreator } from 'zustand';
|
||||
|
||||
// ─── Helper Functions ────────────────────────────────────────────────────────────
|
||||
|
||||
let instanceIdCounter = 0;
|
||||
function generateInstanceId(): string {
|
||||
return `equip_${Date.now()}_${++instanceIdCounter}`;
|
||||
}
|
||||
|
||||
let designIdCounter = 0;
|
||||
function generateDesignId(): string {
|
||||
return `design_${Date.now()}_${++designIdCounter}`;
|
||||
}
|
||||
|
||||
// Calculate efficiency bonus from skills
|
||||
function getEnchantEfficiencyBonus(skills: Record<string, number>): number {
|
||||
const enchantingLevel = skills.enchanting || 0;
|
||||
const efficientEnchantLevel = skills.efficientEnchant || 0;
|
||||
|
||||
// 2% per enchanting level + 5% per efficient enchant level
|
||||
return (enchantingLevel * 0.02) + (efficientEnchantLevel * 0.05);
|
||||
}
|
||||
|
||||
// Calculate design time based on effects
|
||||
function calculateDesignTime(effects: DesignEffect[]): number {
|
||||
const totalCapacity = effects.reduce((sum, e) => sum + e.capacityCost, 0);
|
||||
return Math.max(1, Math.floor(totalCapacity / 10)); // Hours
|
||||
}
|
||||
|
||||
// Calculate preparation time for equipment
|
||||
function calculatePreparationTime(equipmentType: string): number {
|
||||
const typeDef = getEquipmentType(equipmentType);
|
||||
if (!typeDef) return 1;
|
||||
return Math.max(1, Math.floor(typeDef.baseCapacity / 5)); // Hours
|
||||
}
|
||||
|
||||
// Calculate preparation mana cost
|
||||
function calculatePreparationManaCost(equipmentType: string): number {
|
||||
const typeDef = getEquipmentType(equipmentType);
|
||||
if (!typeDef) return 50;
|
||||
return typeDef.baseCapacity * 5;
|
||||
}
|
||||
|
||||
// Calculate application time based on effects
|
||||
function calculateApplicationTime(effects: DesignEffect[], skills: Record<string, number>): number {
|
||||
const totalCapacity = effects.reduce((sum, e) => sum + e.capacityCost, 0);
|
||||
const speedBonus = 1 + (skills.enchantSpeed || 0) * 0.1;
|
||||
return Math.max(4, Math.floor(totalCapacity / 20 * 24 / speedBonus)); // Hours (days * 24)
|
||||
}
|
||||
|
||||
// Calculate mana per hour for application
|
||||
function calculateApplicationManaPerHour(effects: DesignEffect[]): number {
|
||||
const totalCapacity = effects.reduce((sum, e) => sum + e.capacityCost, 0);
|
||||
return Math.max(1, Math.floor(totalCapacity * 0.5));
|
||||
}
|
||||
|
||||
// Create a new equipment instance
|
||||
export function createEquipmentInstance(typeId: string, name?: string): EquipmentInstance {
|
||||
const typeDef = getEquipmentType(typeId);
|
||||
if (!typeDef) {
|
||||
throw new Error(`Unknown equipment type: ${typeId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
instanceId: generateInstanceId(),
|
||||
typeId,
|
||||
name: name || typeDef.name,
|
||||
enchantments: [],
|
||||
usedCapacity: 0,
|
||||
totalCapacity: typeDef.baseCapacity,
|
||||
rarity: 'common',
|
||||
quality: 100, // Full quality for new items
|
||||
};
|
||||
}
|
||||
|
||||
// Get spells from equipment
|
||||
export function getSpellsFromEquipment(equipment: EquipmentInstance): string[] {
|
||||
const spells: string[] = [];
|
||||
|
||||
for (const ench of equipment.enchantments) {
|
||||
const effectDef = getEnchantmentEffect(ench.effectId);
|
||||
if (effectDef?.effect.type === 'spell' && effectDef.effect.spellId) {
|
||||
spells.push(effectDef.effect.spellId);
|
||||
}
|
||||
}
|
||||
|
||||
return spells;
|
||||
}
|
||||
|
||||
// Compute total effects from equipment
|
||||
export function computeEquipmentEffects(equipment: EquipmentInstance[]): Record<string, number> {
|
||||
const effects: Record<string, number> = {};
|
||||
const multipliers: Record<string, number> = {};
|
||||
const specials: Set<string> = new Set();
|
||||
|
||||
for (const equip of equipment) {
|
||||
for (const ench of equip.enchantments) {
|
||||
const effectDef = getEnchantmentEffect(ench.effectId);
|
||||
if (!effectDef) continue;
|
||||
|
||||
const value = (effectDef.effect.value || 0) * ench.stacks;
|
||||
|
||||
if (effectDef.effect.type === 'bonus' && effectDef.effect.stat) {
|
||||
effects[effectDef.effect.stat] = (effects[effectDef.effect.stat] || 0) + value;
|
||||
} else if (effectDef.effect.type === 'multiplier' && effectDef.effect.stat) {
|
||||
multipliers[effectDef.effect.stat] = (multipliers[effectDef.effect.stat] || 1) * Math.pow(value, ench.stacks);
|
||||
} else if (effectDef.effect.type === 'special' && effectDef.effect.specialId) {
|
||||
specials.add(effectDef.effect.specialId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply multipliers to bonus effects
|
||||
for (const [stat, mult] of Object.entries(multipliers)) {
|
||||
effects[`${stat}_multiplier`] = mult;
|
||||
}
|
||||
|
||||
// Add special effect flags
|
||||
for (const special of specials) {
|
||||
effects[`special_${special}`] = 1;
|
||||
}
|
||||
|
||||
return effects;
|
||||
}
|
||||
|
||||
// ─── Store Interface ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CraftingState {
|
||||
// Equipment instances
|
||||
equippedInstances: Record<string, string | null>; // slot -> instanceId
|
||||
equipmentInstances: Record<string, EquipmentInstance>; // instanceId -> instance
|
||||
|
||||
// Enchantment designs
|
||||
enchantmentDesigns: EnchantmentDesign[];
|
||||
|
||||
// Crafting progress
|
||||
designProgress: DesignProgress | null;
|
||||
preparationProgress: PreparationProgress | null;
|
||||
applicationProgress: ApplicationProgress | null;
|
||||
|
||||
// Equipment spell states
|
||||
equipmentSpellStates: EquipmentSpellState[];
|
||||
}
|
||||
|
||||
export interface CraftingActions {
|
||||
// Equipment management
|
||||
createEquipment: (typeId: string, slot?: EquipmentSlot) => EquipmentInstance;
|
||||
equipInstance: (instanceId: string, slot: EquipmentSlot) => void;
|
||||
unequipSlot: (slot: EquipmentSlot) => void;
|
||||
deleteInstance: (instanceId: string) => void;
|
||||
|
||||
// Enchantment design
|
||||
startDesign: (name: string, equipmentType: string, effects: DesignEffect[]) => void;
|
||||
cancelDesign: () => void;
|
||||
deleteDesign: (designId: string) => void;
|
||||
|
||||
// Equipment preparation
|
||||
startPreparation: (instanceId: string) => void;
|
||||
cancelPreparation: () => void;
|
||||
|
||||
// Enchantment application
|
||||
startApplication: (instanceId: string, designId: string) => void;
|
||||
pauseApplication: () => void;
|
||||
resumeApplication: () => void;
|
||||
cancelApplication: () => void;
|
||||
|
||||
// Tick processing
|
||||
processDesignTick: (hours: number) => void;
|
||||
processPreparationTick: (hours: number, manaAvailable: number) => number; // Returns mana used
|
||||
processApplicationTick: (hours: number, manaAvailable: number) => number; // Returns mana used
|
||||
|
||||
// Getters
|
||||
getEquippedInstance: (slot: EquipmentSlot) => EquipmentInstance | null;
|
||||
getAllEquipped: () => EquipmentInstance[];
|
||||
getAvailableSpells: () => string[];
|
||||
getEquipmentEffects: () => Record<string, number>;
|
||||
}
|
||||
|
||||
export type CraftingStore = CraftingState & CraftingActions;
|
||||
|
||||
// ─── Initial State ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const initialCraftingState: CraftingState = {
|
||||
equippedInstances: {
|
||||
mainHand: null,
|
||||
offHand: null,
|
||||
head: null,
|
||||
body: null,
|
||||
hands: null,
|
||||
feet: null,
|
||||
accessory1: null,
|
||||
accessory2: null,
|
||||
},
|
||||
equipmentInstances: {},
|
||||
enchantmentDesigns: [],
|
||||
designProgress: null,
|
||||
preparationProgress: null,
|
||||
applicationProgress: null,
|
||||
equipmentSpellStates: [],
|
||||
};
|
||||
|
||||
// ─── Store Slice Creator ────────────────────────────────────────────────────────
|
||||
|
||||
// We need to access skills from the main store - this is a workaround
|
||||
// The store will pass skills when calling these methods
|
||||
let cachedSkills: Record<string, number> = {};
|
||||
|
||||
export function setCachedSkills(skills: Record<string, number>): void {
|
||||
cachedSkills = skills;
|
||||
}
|
||||
|
||||
export const createCraftingSlice: StateCreator<CraftingStore, [], [], CraftingStore> = (set, get) => ({
|
||||
...initialCraftingState,
|
||||
|
||||
// Equipment management
|
||||
createEquipment: (typeId: string, slot?: EquipmentSlot) => {
|
||||
const instance = createEquipmentInstance(typeId);
|
||||
|
||||
set((state) => ({
|
||||
equipmentInstances: {
|
||||
...state.equipmentInstances,
|
||||
[instance.instanceId]: instance,
|
||||
},
|
||||
}));
|
||||
|
||||
// Auto-equip if slot provided
|
||||
if (slot) {
|
||||
get().equipInstance(instance.instanceId, slot);
|
||||
}
|
||||
|
||||
return instance;
|
||||
},
|
||||
|
||||
equipInstance: (instanceId: string, slot: EquipmentSlot) => {
|
||||
const instance = get().equipmentInstances[instanceId];
|
||||
if (!instance) return;
|
||||
|
||||
const typeDef = getEquipmentType(instance.typeId);
|
||||
if (!typeDef) return;
|
||||
|
||||
// Check if equipment can go in this slot
|
||||
if (typeDef.slot !== slot) {
|
||||
// For accessories, both accessory1 and accessory2 are valid
|
||||
if (typeDef.category !== 'accessory' || (slot !== 'accessory1' && slot !== 'accessory2')) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
equippedInstances: {
|
||||
...state.equippedInstances,
|
||||
[slot]: instanceId,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
unequipSlot: (slot: EquipmentSlot) => {
|
||||
set((state) => ({
|
||||
equippedInstances: {
|
||||
...state.equippedInstances,
|
||||
[slot]: null,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
deleteInstance: (instanceId: string) => {
|
||||
set((state) => {
|
||||
const newInstanceMap = { ...state.equipmentInstances };
|
||||
delete newInstanceMap[instanceId];
|
||||
|
||||
// Remove from equipped slots
|
||||
const newEquipped = { ...state.equippedInstances };
|
||||
for (const slot of EQUIPMENT_SLOTS) {
|
||||
if (newEquipped[slot] === instanceId) {
|
||||
newEquipped[slot] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
equipmentInstances: newInstanceMap,
|
||||
equippedInstances: newEquipped,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
// Enchantment design
|
||||
startDesign: (name: string, equipmentType: string, effects: DesignEffect[]) => {
|
||||
const totalCapacity = effects.reduce((sum, e) => sum + e.capacityCost, 0);
|
||||
const designTime = calculateDesignTime(effects);
|
||||
|
||||
const design: EnchantmentDesign = {
|
||||
id: generateDesignId(),
|
||||
name,
|
||||
equipmentType,
|
||||
effects,
|
||||
totalCapacityUsed: totalCapacity,
|
||||
designTime,
|
||||
created: Date.now(),
|
||||
};
|
||||
|
||||
set((state) => ({
|
||||
enchantmentDesigns: [...state.enchantmentDesigns, design],
|
||||
designProgress: {
|
||||
designId: design.id,
|
||||
progress: 0,
|
||||
required: designTime,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
cancelDesign: () => {
|
||||
const progress = get().designProgress;
|
||||
if (!progress) return;
|
||||
|
||||
set((state) => ({
|
||||
designProgress: null,
|
||||
enchantmentDesigns: state.enchantmentDesigns.filter(d => d.id !== progress.designId),
|
||||
}));
|
||||
},
|
||||
|
||||
deleteDesign: (designId: string) => {
|
||||
set((state) => ({
|
||||
enchantmentDesigns: state.enchantmentDesigns.filter(d => d.id !== designId),
|
||||
}));
|
||||
},
|
||||
|
||||
// Equipment preparation
|
||||
startPreparation: (instanceId: string) => {
|
||||
const instance = get().equipmentInstances[instanceId];
|
||||
if (!instance) return;
|
||||
|
||||
const prepTime = calculatePreparationTime(instance.typeId);
|
||||
const manaCost = calculatePreparationManaCost(instance.typeId);
|
||||
|
||||
set({
|
||||
preparationProgress: {
|
||||
equipmentInstanceId: instanceId,
|
||||
progress: 0,
|
||||
required: prepTime,
|
||||
manaCostPaid: 0,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
cancelPreparation: () => {
|
||||
set({ preparationProgress: null });
|
||||
},
|
||||
|
||||
// Enchantment application
|
||||
startApplication: (instanceId: string, designId: string) => {
|
||||
const instance = get().equipmentInstances[instanceId];
|
||||
const design = get().enchantmentDesigns.find(d => d.id === designId);
|
||||
|
||||
if (!instance || !design) return;
|
||||
|
||||
const appTime = calculateApplicationTime(design.effects, cachedSkills);
|
||||
const manaPerHour = calculateApplicationManaPerHour(design.effects);
|
||||
|
||||
set({
|
||||
applicationProgress: {
|
||||
equipmentInstanceId: instanceId,
|
||||
designId,
|
||||
progress: 0,
|
||||
required: appTime,
|
||||
manaPerHour,
|
||||
paused: false,
|
||||
manaSpent: 0,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
pauseApplication: () => {
|
||||
const progress = get().applicationProgress;
|
||||
if (!progress) return;
|
||||
|
||||
set({
|
||||
applicationProgress: { ...progress, paused: true },
|
||||
});
|
||||
},
|
||||
|
||||
resumeApplication: () => {
|
||||
const progress = get().applicationProgress;
|
||||
if (!progress) return;
|
||||
|
||||
set({
|
||||
applicationProgress: { ...progress, paused: false },
|
||||
});
|
||||
},
|
||||
|
||||
cancelApplication: () => {
|
||||
set({ applicationProgress: null });
|
||||
},
|
||||
|
||||
// Tick processing
|
||||
processDesignTick: (hours: number) => {
|
||||
const progress = get().designProgress;
|
||||
if (!progress) return;
|
||||
|
||||
const newProgress = progress.progress + hours;
|
||||
|
||||
if (newProgress >= progress.required) {
|
||||
// Design complete
|
||||
set({ designProgress: null });
|
||||
} else {
|
||||
set({
|
||||
designProgress: { ...progress, progress: newProgress },
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
processPreparationTick: (hours: number, manaAvailable: number) => {
|
||||
const progress = get().preparationProgress;
|
||||
if (!progress) return 0;
|
||||
|
||||
const instance = get().equipmentInstances[progress.equipmentInstanceId];
|
||||
if (!instance) {
|
||||
set({ preparationProgress: null });
|
||||
return 0;
|
||||
}
|
||||
|
||||
const totalManaCost = calculatePreparationManaCost(instance.typeId);
|
||||
const remainingManaCost = totalManaCost - progress.manaCostPaid;
|
||||
const manaToPay = Math.min(manaAvailable, remainingManaCost);
|
||||
|
||||
if (manaToPay < remainingManaCost) {
|
||||
// Not enough mana, just pay what we can
|
||||
set({
|
||||
preparationProgress: {
|
||||
...progress,
|
||||
manaCostPaid: progress.manaCostPaid + manaToPay,
|
||||
},
|
||||
});
|
||||
return manaToPay;
|
||||
}
|
||||
|
||||
// Pay remaining mana and progress
|
||||
const newProgress = progress.progress + hours;
|
||||
|
||||
if (newProgress >= progress.required) {
|
||||
// Preparation complete - clear enchantments
|
||||
set((state) => ({
|
||||
preparationProgress: null,
|
||||
equipmentInstances: {
|
||||
...state.equipmentInstances,
|
||||
[instance.instanceId]: {
|
||||
...instance,
|
||||
enchantments: [],
|
||||
usedCapacity: 0,
|
||||
rarity: 'common',
|
||||
},
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
set({
|
||||
preparationProgress: {
|
||||
...progress,
|
||||
progress: newProgress,
|
||||
manaCostPaid: progress.manaCostPaid + manaToPay,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return manaToPay;
|
||||
},
|
||||
|
||||
processApplicationTick: (hours: number, manaAvailable: number) => {
|
||||
const progress = get().applicationProgress;
|
||||
if (!progress || progress.paused) return 0;
|
||||
|
||||
const design = get().enchantmentDesigns.find(d => d.id === progress.designId);
|
||||
const instance = get().equipmentInstances[progress.equipmentInstanceId];
|
||||
|
||||
if (!design || !instance) {
|
||||
set({ applicationProgress: null });
|
||||
return 0;
|
||||
}
|
||||
|
||||
const manaNeeded = progress.manaPerHour * hours;
|
||||
const manaToUse = Math.min(manaAvailable, manaNeeded);
|
||||
|
||||
if (manaToUse < manaNeeded) {
|
||||
// Not enough mana - pause and save progress
|
||||
set({
|
||||
applicationProgress: {
|
||||
...progress,
|
||||
manaSpent: progress.manaSpent + manaToUse,
|
||||
},
|
||||
});
|
||||
return manaToUse;
|
||||
}
|
||||
|
||||
const newProgress = progress.progress + hours;
|
||||
|
||||
if (newProgress >= progress.required) {
|
||||
// Application complete - apply enchantments
|
||||
const efficiencyBonus = getEnchantEfficiencyBonus(cachedSkills);
|
||||
const newEnchantments: AppliedEnchantment[] = design.effects.map(e => ({
|
||||
effectId: e.effectId,
|
||||
stacks: e.stacks,
|
||||
actualCost: calculateEffectCapacityCost(e.effectId, e.stacks, efficiencyBonus),
|
||||
}));
|
||||
|
||||
const totalUsedCapacity = newEnchantments.reduce((sum, e) => sum + e.actualCost, 0);
|
||||
|
||||
set((state) => ({
|
||||
applicationProgress: null,
|
||||
equipmentInstances: {
|
||||
...state.equipmentInstances,
|
||||
[instance.instanceId]: {
|
||||
...instance,
|
||||
enchantments: newEnchantments,
|
||||
usedCapacity: totalUsedCapacity,
|
||||
rarity: calculateRarity(newEnchantments),
|
||||
},
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
set({
|
||||
applicationProgress: {
|
||||
...progress,
|
||||
progress: newProgress,
|
||||
manaSpent: progress.manaSpent + manaToUse,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return manaToUse;
|
||||
},
|
||||
|
||||
// Getters
|
||||
getEquippedInstance: (slot: EquipmentSlot) => {
|
||||
const state = get();
|
||||
const instanceId = state.equippedInstances[slot];
|
||||
if (!instanceId) return null;
|
||||
return state.equipmentInstances[instanceId] || null;
|
||||
},
|
||||
|
||||
getAllEquipped: () => {
|
||||
const state = get();
|
||||
const equipped: EquipmentInstance[] = [];
|
||||
|
||||
for (const slot of EQUIPMENT_SLOTS) {
|
||||
const instanceId = state.equippedInstances[slot];
|
||||
if (instanceId && state.equipmentInstances[instanceId]) {
|
||||
equipped.push(state.equipmentInstances[instanceId]);
|
||||
}
|
||||
}
|
||||
|
||||
return equipped;
|
||||
},
|
||||
|
||||
getAvailableSpells: () => {
|
||||
const equipped = get().getAllEquipped();
|
||||
const spells: string[] = [];
|
||||
|
||||
for (const equip of equipped) {
|
||||
spells.push(...getSpellsFromEquipment(equip));
|
||||
}
|
||||
|
||||
return spells;
|
||||
},
|
||||
|
||||
getEquipmentEffects: () => {
|
||||
return computeEquipmentEffects(get().getAllEquipped());
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Starting Equipment Factory ────────────────────────────────────────────────
|
||||
|
||||
export function createStartingEquipment(): {
|
||||
equippedInstances: Record<string, string | null>;
|
||||
equipmentInstances: Record<string, EquipmentInstance>;
|
||||
} {
|
||||
const instances: EquipmentInstance[] = [];
|
||||
|
||||
// Create starting equipment
|
||||
const basicStaff = createEquipmentInstance('basicStaff');
|
||||
basicStaff.enchantments = [{
|
||||
effectId: 'spell_manaBolt',
|
||||
stacks: 1,
|
||||
actualCost: 50, // Fills the staff completely
|
||||
}];
|
||||
basicStaff.usedCapacity = 50;
|
||||
basicStaff.rarity = 'uncommon';
|
||||
instances.push(basicStaff);
|
||||
|
||||
const civilianShirt = createEquipmentInstance('civilianShirt');
|
||||
instances.push(civilianShirt);
|
||||
|
||||
const civilianGloves = createEquipmentInstance('civilianGloves');
|
||||
instances.push(civilianGloves);
|
||||
|
||||
const civilianShoes = createEquipmentInstance('civilianShoes');
|
||||
instances.push(civilianShoes);
|
||||
|
||||
// Build instance map
|
||||
const equipmentInstances: Record<string, EquipmentInstance> = {};
|
||||
for (const inst of instances) {
|
||||
equipmentInstances[inst.instanceId] = inst;
|
||||
}
|
||||
|
||||
// Build equipped map
|
||||
const equippedInstances: Record<string, string | null> = {
|
||||
mainHand: basicStaff.instanceId,
|
||||
offHand: null,
|
||||
head: null,
|
||||
body: civilianShirt.instanceId,
|
||||
hands: civilianGloves.instanceId,
|
||||
feet: civilianShoes.instanceId,
|
||||
accessory1: null,
|
||||
accessory2: null,
|
||||
};
|
||||
|
||||
return { equippedInstances, equipmentInstances };
|
||||
}
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
// ─── Store Module Exports ─────────────────────────────────────────────────────
|
||||
// Re-exports from main store and adds new computed utilities
|
||||
// This allows gradual migration while keeping existing functionality
|
||||
|
||||
// Re-export everything from the main store
|
||||
export * from '../store';
|
||||
|
||||
// Export new computed utilities
|
||||
export * from './computed';
|
||||
Executable
+197
@@ -0,0 +1,197 @@
|
||||
// ─── Mana Slice ───────────────────────────────────────────────────────────────
|
||||
// Manages raw mana, elements, and meditation
|
||||
|
||||
import type { StateCreator } from 'zustand';
|
||||
import type { GameState, ElementState, SpellCost } from '../types';
|
||||
import { ELEMENTS, MANA_PER_ELEMENT, BASE_UNLOCKED_ELEMENTS } from '../constants';
|
||||
import { computeMaxMana, computeElementMax, computeClickMana, canAffordSpellCost, getMeditationBonus } from './computed';
|
||||
import { computeEffects } from '../upgrade-effects';
|
||||
|
||||
export interface ManaSlice {
|
||||
// State
|
||||
rawMana: number;
|
||||
totalManaGathered: number;
|
||||
meditateTicks: number;
|
||||
elements: Record<string, ElementState>;
|
||||
|
||||
// Actions
|
||||
gatherMana: () => void;
|
||||
convertMana: (element: string, amount: number) => void;
|
||||
unlockElement: (element: string) => void;
|
||||
craftComposite: (target: string) => void;
|
||||
|
||||
// Computed getters
|
||||
getMaxMana: () => number;
|
||||
getRegen: () => number;
|
||||
getClickMana: () => number;
|
||||
getMeditationMultiplier: () => number;
|
||||
}
|
||||
|
||||
export const createManaSlice = (
|
||||
set: StateCreator<GameState>['set'],
|
||||
get: () => GameState
|
||||
): ManaSlice => ({
|
||||
rawMana: 10,
|
||||
totalManaGathered: 0,
|
||||
meditateTicks: 0,
|
||||
elements: (() => {
|
||||
const elems: Record<string, ElementState> = {};
|
||||
const pu = get().prestigeUpgrades;
|
||||
const elemMax = computeElementMax(get());
|
||||
|
||||
Object.keys(ELEMENTS).forEach((k) => {
|
||||
const isUnlocked = BASE_UNLOCKED_ELEMENTS.includes(k);
|
||||
let startAmount = 0;
|
||||
|
||||
if (isUnlocked && pu.elemStart) {
|
||||
startAmount = pu.elemStart * 5;
|
||||
}
|
||||
|
||||
elems[k] = {
|
||||
current: startAmount,
|
||||
max: elemMax,
|
||||
unlocked: isUnlocked,
|
||||
};
|
||||
});
|
||||
return elems;
|
||||
})(),
|
||||
|
||||
gatherMana: () => {
|
||||
const state = get();
|
||||
let cm = computeClickMana(state);
|
||||
|
||||
// Mana overflow bonus
|
||||
const overflowBonus = 1 + (state.skills.manaOverflow || 0) * 0.25;
|
||||
cm = Math.floor(cm * overflowBonus);
|
||||
|
||||
const effects = computeEffects(state.skillUpgrades || {}, state.skillTiers || {});
|
||||
const max = computeMaxMana(state, effects);
|
||||
|
||||
// Mana Echo: 10% chance to gain double mana from clicks
|
||||
const hasManaEcho = effects.specials?.has('MANA_ECHO') ?? false;
|
||||
if (hasManaEcho && Math.random() < 0.1) {
|
||||
cm *= 2;
|
||||
}
|
||||
|
||||
set({
|
||||
rawMana: Math.min(state.rawMana + cm, max),
|
||||
totalManaGathered: state.totalManaGathered + cm,
|
||||
});
|
||||
},
|
||||
|
||||
convertMana: (element: string, amount: number = 1) => {
|
||||
const state = get();
|
||||
const e = state.elements[element];
|
||||
if (!e?.unlocked) return;
|
||||
|
||||
const cost = MANA_PER_ELEMENT * amount;
|
||||
if (state.rawMana < cost) return;
|
||||
if (e.current >= e.max) return;
|
||||
|
||||
const canConvert = Math.min(
|
||||
amount,
|
||||
Math.floor(state.rawMana / MANA_PER_ELEMENT),
|
||||
e.max - e.current
|
||||
);
|
||||
|
||||
set({
|
||||
rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT,
|
||||
elements: {
|
||||
...state.elements,
|
||||
[element]: { ...e, current: e.current + canConvert },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
unlockElement: (element: string) => {
|
||||
const state = get();
|
||||
if (state.elements[element]?.unlocked) return;
|
||||
|
||||
const cost = 500;
|
||||
if (state.rawMana < cost) return;
|
||||
|
||||
set({
|
||||
rawMana: state.rawMana - cost,
|
||||
elements: {
|
||||
...state.elements,
|
||||
[element]: { ...state.elements[element], unlocked: true },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
craftComposite: (target: string) => {
|
||||
const state = get();
|
||||
const edef = ELEMENTS[target];
|
||||
if (!edef?.recipe) return;
|
||||
|
||||
const recipe = edef.recipe;
|
||||
const costs: Record<string, number> = {};
|
||||
recipe.forEach((r) => {
|
||||
costs[r] = (costs[r] || 0) + 1;
|
||||
});
|
||||
|
||||
// Check ingredients
|
||||
for (const [r, amt] of Object.entries(costs)) {
|
||||
if ((state.elements[r]?.current || 0) < amt) return;
|
||||
}
|
||||
|
||||
const newElems = { ...state.elements };
|
||||
for (const [r, amt] of Object.entries(costs)) {
|
||||
newElems[r] = { ...newElems[r], current: newElems[r].current - amt };
|
||||
}
|
||||
|
||||
// Elemental crafting bonus
|
||||
const craftBonus = 1 + (state.skills.elemCrafting || 0) * 0.25;
|
||||
const outputAmount = Math.floor(craftBonus);
|
||||
|
||||
const effects = computeEffects(state.skillUpgrades || {}, state.skillTiers || {});
|
||||
const elemMax = computeElementMax(state, effects);
|
||||
newElems[target] = {
|
||||
...(newElems[target] || { current: 0, max: elemMax, unlocked: false }),
|
||||
current: (newElems[target]?.current || 0) + outputAmount,
|
||||
max: elemMax,
|
||||
unlocked: true,
|
||||
};
|
||||
|
||||
set({
|
||||
elements: newElems,
|
||||
});
|
||||
},
|
||||
|
||||
getMaxMana: () => computeMaxMana(get()),
|
||||
getRegen: () => {
|
||||
const state = get();
|
||||
const effects = computeEffects(state.skillUpgrades || {}, state.skillTiers || {});
|
||||
// This would need proper regen calculation
|
||||
return 2;
|
||||
},
|
||||
getClickMana: () => computeClickMana(get()),
|
||||
getMeditationMultiplier: () => {
|
||||
const state = get();
|
||||
const effects = computeEffects(state.skillUpgrades || {}, state.skillTiers || {});
|
||||
return getMeditationBonus(state.meditateTicks, state.skills, effects.meditationEfficiency);
|
||||
},
|
||||
});
|
||||
|
||||
// Helper function to deduct spell cost
|
||||
export function deductSpellCost(
|
||||
cost: SpellCost,
|
||||
rawMana: number,
|
||||
elements: Record<string, ElementState>
|
||||
): { rawMana: number; elements: Record<string, ElementState> } {
|
||||
const newElements = { ...elements };
|
||||
|
||||
if (cost.type === 'raw') {
|
||||
return { rawMana: rawMana - cost.amount, elements: newElements };
|
||||
} else if (cost.element && newElements[cost.element]) {
|
||||
newElements[cost.element] = {
|
||||
...newElements[cost.element],
|
||||
current: newElements[cost.element].current - cost.amount,
|
||||
};
|
||||
return { rawMana, elements: newElements };
|
||||
}
|
||||
|
||||
return { rawMana, elements: newElements };
|
||||
}
|
||||
|
||||
export { canAffordSpellCost };
|
||||
Executable
+180
@@ -0,0 +1,180 @@
|
||||
// ─── Pact Slice ───────────────────────────────────────────────────────────────
|
||||
// Manages guardian pacts, signing, and mana unlocking
|
||||
|
||||
import type { StateCreator } from 'zustand';
|
||||
import type { GameState } from '../types';
|
||||
import { GUARDIANS, ELEMENTS } from '../constants';
|
||||
import { computePactMultiplier, computePactInsightMultiplier } from './computed';
|
||||
|
||||
export interface PactSlice {
|
||||
// State
|
||||
signedPacts: number[];
|
||||
pendingPactOffer: number | null;
|
||||
maxPacts: number;
|
||||
pactSigningProgress: {
|
||||
floor: number;
|
||||
progress: number;
|
||||
required: number;
|
||||
manaCost: number;
|
||||
} | null;
|
||||
signedPactDetails: Record<number, {
|
||||
floor: number;
|
||||
guardianId: string;
|
||||
signedAt: { day: number; hour: number };
|
||||
skillLevels: Record<string, number>;
|
||||
}>;
|
||||
pactInterferenceMitigation: number;
|
||||
pactSynergyUnlocked: boolean;
|
||||
|
||||
// Actions
|
||||
acceptPact: (floor: number) => void;
|
||||
declinePact: (floor: number) => void;
|
||||
|
||||
// Computed getters
|
||||
getPactMultiplier: () => number;
|
||||
getPactInsightMultiplier: () => number;
|
||||
}
|
||||
|
||||
export const createPactSlice = (
|
||||
set: StateCreator<GameState>['set'],
|
||||
get: () => GameState
|
||||
): PactSlice => ({
|
||||
signedPacts: [],
|
||||
pendingPactOffer: null,
|
||||
maxPacts: 1,
|
||||
pactSigningProgress: null,
|
||||
signedPactDetails: {},
|
||||
pactInterferenceMitigation: 0,
|
||||
pactSynergyUnlocked: false,
|
||||
|
||||
acceptPact: (floor: number) => {
|
||||
const state = get();
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian || state.signedPacts.includes(floor)) return;
|
||||
|
||||
const maxPacts = 1 + (state.prestigeUpgrades.pactCapacity || 0);
|
||||
if (state.signedPacts.length >= maxPacts) {
|
||||
set({
|
||||
log: [`⚠️ Cannot sign more pacts! Maximum: ${maxPacts}.`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const baseCost = guardian.signingCost.mana;
|
||||
const discount = Math.min((state.prestigeUpgrades.pactDiscount || 0) * 0.1, 0.5);
|
||||
const manaCost = Math.floor(baseCost * (1 - discount));
|
||||
|
||||
if (state.rawMana < manaCost) {
|
||||
set({
|
||||
log: [`⚠️ Need ${manaCost} mana to sign pact with ${guardian.name}!`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const baseTime = guardian.signingCost.time;
|
||||
const haste = Math.min((state.prestigeUpgrades.pactHaste || 0) * 0.1, 0.5);
|
||||
const signingTime = Math.max(1, baseTime * (1 - haste));
|
||||
|
||||
set({
|
||||
rawMana: state.rawMana - manaCost,
|
||||
pactSigningProgress: {
|
||||
floor,
|
||||
progress: 0,
|
||||
required: signingTime,
|
||||
manaCost,
|
||||
},
|
||||
pendingPactOffer: null,
|
||||
currentAction: 'study',
|
||||
log: [`📜 Beginning pact signing with ${guardian.name}... (${signingTime}h, ${manaCost} mana)`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
declinePact: (floor: number) => {
|
||||
const state = get();
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian) return;
|
||||
|
||||
set({
|
||||
pendingPactOffer: null,
|
||||
log: [`🚫 Declined pact with ${guardian.name}.`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
getPactMultiplier: () => computePactMultiplier(get()),
|
||||
getPactInsightMultiplier: () => computePactInsightMultiplier(get()),
|
||||
});
|
||||
|
||||
// Process pact signing progress (called during tick)
|
||||
export function processPactSigning(state: GameState, deltaHours: number): Partial<GameState> {
|
||||
if (!state.pactSigningProgress) return {};
|
||||
|
||||
const progress = state.pactSigningProgress.progress + deltaHours;
|
||||
const log = [...state.log];
|
||||
|
||||
if (progress >= state.pactSigningProgress.required) {
|
||||
const floor = state.pactSigningProgress.floor;
|
||||
const guardian = GUARDIANS[floor];
|
||||
if (!guardian || state.signedPacts.includes(floor)) {
|
||||
return { pactSigningProgress: null };
|
||||
}
|
||||
|
||||
const signedPacts = [...state.signedPacts, floor];
|
||||
const signedPactDetails = {
|
||||
...state.signedPactDetails,
|
||||
[floor]: {
|
||||
floor,
|
||||
guardianId: guardian.element,
|
||||
signedAt: { day: state.day, hour: state.hour },
|
||||
skillLevels: {},
|
||||
},
|
||||
};
|
||||
|
||||
// Unlock mana types
|
||||
let elements = { ...state.elements };
|
||||
for (const elemId of guardian.unlocksMana) {
|
||||
if (elements[elemId]) {
|
||||
elements = {
|
||||
...elements,
|
||||
[elemId]: { ...elements[elemId], unlocked: true },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for compound element unlocks
|
||||
const unlockedSet = new Set(
|
||||
Object.entries(elements)
|
||||
.filter(([, e]) => e.unlocked)
|
||||
.map(([id]) => id)
|
||||
);
|
||||
|
||||
for (const [elemId, elemDef] of Object.entries(ELEMENTS)) {
|
||||
if (elemDef.recipe && !elements[elemId]?.unlocked) {
|
||||
const canUnlock = elemDef.recipe.every(comp => unlockedSet.has(comp));
|
||||
if (canUnlock) {
|
||||
elements = {
|
||||
...elements,
|
||||
[elemId]: { ...elements[elemId], unlocked: true },
|
||||
};
|
||||
log.unshift(`🔮 ${elemDef.name} mana unlocked through component synergy!`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.unshift(`📜 Pact with ${guardian.name} signed! ${guardian.unlocksMana.map(e => ELEMENTS[e]?.name || e).join(', ')} mana unlocked!`);
|
||||
|
||||
return {
|
||||
signedPacts,
|
||||
signedPactDetails,
|
||||
elements,
|
||||
pactSigningProgress: null,
|
||||
log,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
pactSigningProgress: {
|
||||
...state.pactSigningProgress,
|
||||
progress,
|
||||
},
|
||||
};
|
||||
}
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
// ─── Prestige Slice ───────────────────────────────────────────────────────────
|
||||
// Manages insight, prestige upgrades, and loop resources
|
||||
|
||||
import type { StateCreator } from 'zustand';
|
||||
import type { GameState } from '../types';
|
||||
import { PRESTIGE_DEF } from '../constants';
|
||||
|
||||
export interface PrestigeSlice {
|
||||
// State
|
||||
insight: number;
|
||||
totalInsight: number;
|
||||
prestigeUpgrades: Record<string, number>;
|
||||
loopInsight: number;
|
||||
memorySlots: number;
|
||||
memories: string[];
|
||||
|
||||
// Actions
|
||||
doPrestige: (id: string) => void;
|
||||
startNewLoop: () => void;
|
||||
}
|
||||
|
||||
export const createPrestigeSlice = (
|
||||
set: StateCreator<GameState>['set'],
|
||||
get: () => GameState
|
||||
): PrestigeSlice => ({
|
||||
insight: 0,
|
||||
totalInsight: 0,
|
||||
prestigeUpgrades: {},
|
||||
loopInsight: 0,
|
||||
memorySlots: 3,
|
||||
memories: [],
|
||||
|
||||
doPrestige: (id: string) => {
|
||||
const state = get();
|
||||
const pd = PRESTIGE_DEF[id];
|
||||
if (!pd) return;
|
||||
|
||||
const lvl = state.prestigeUpgrades[id] || 0;
|
||||
if (lvl >= pd.max || state.insight < pd.cost) return;
|
||||
|
||||
const newPU = { ...state.prestigeUpgrades, [id]: lvl + 1 };
|
||||
|
||||
set({
|
||||
insight: state.insight - pd.cost,
|
||||
prestigeUpgrades: newPU,
|
||||
memorySlots: id === 'deepMemory' ? state.memorySlots + 1 : state.memorySlots,
|
||||
maxPacts: id === 'pactCapacity' ? state.maxPacts + 1 : state.maxPacts,
|
||||
pactInterferenceMitigation: id === 'pactInterference' ? (state.pactInterferenceMitigation || 0) + 1 : state.pactInterferenceMitigation,
|
||||
log: [`⭐ ${pd.name} upgraded to Lv.${lvl + 1}!`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
startNewLoop: () => {
|
||||
const state = get();
|
||||
const insightGained = state.loopInsight || calcInsight(state);
|
||||
const total = state.insight + insightGained;
|
||||
|
||||
// Reset to initial state with insight carried over
|
||||
const pu = state.prestigeUpgrades;
|
||||
const startFloor = 1 + (pu.spireKey || 0) * 2;
|
||||
const startRawMana = 10 + (pu.manaWell || 0) * 500 + (pu.quickStart || 0) * 100;
|
||||
|
||||
// Reset elements
|
||||
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
|
||||
Object.keys(ELEMENTS).forEach((k) => {
|
||||
elements[k] = {
|
||||
current: 0,
|
||||
max: 10 + (pu.elementalAttune || 0) * 25,
|
||||
unlocked: false,
|
||||
};
|
||||
});
|
||||
|
||||
// Reset spells - always start with Mana Bolt
|
||||
const spells: Record<string, { learned: boolean; level: number; studyProgress: number }> = {
|
||||
manaBolt: { learned: true, level: 1, studyProgress: 0 },
|
||||
};
|
||||
|
||||
// Add random starting spells from spell memory prestige upgrade (purchased with insight)
|
||||
if (pu.spellMemory) {
|
||||
const availableSpells = Object.keys(SPELLS_DEF).filter(s => s !== 'manaBolt');
|
||||
const shuffled = availableSpells.sort(() => Math.random() - 0.5);
|
||||
for (let i = 0; i < Math.min(pu.spellMemory, shuffled.length); i++) {
|
||||
spells[shuffled[i]] = { learned: true, level: 1, studyProgress: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
set({
|
||||
day: 1,
|
||||
hour: 0,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
loopCount: state.loopCount + 1,
|
||||
rawMana: startRawMana,
|
||||
totalManaGathered: 0,
|
||||
meditateTicks: 0,
|
||||
elements,
|
||||
currentFloor: startFloor,
|
||||
floorHP: getFloorMaxHP(startFloor),
|
||||
floorMaxHP: getFloorMaxHP(startFloor),
|
||||
maxFloorReached: startFloor,
|
||||
signedPacts: [],
|
||||
pendingPactOffer: null,
|
||||
pactSigningProgress: null,
|
||||
signedPactDetails: {},
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'meditate',
|
||||
castProgress: 0,
|
||||
spells,
|
||||
skills: {},
|
||||
skillProgress: {},
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
currentStudyTarget: null,
|
||||
parallelStudyTarget: null,
|
||||
insight: total,
|
||||
totalInsight: (state.totalInsight || 0) + insightGained,
|
||||
loopInsight: 0,
|
||||
maxPacts: 1 + (pu.pactCapacity || 0),
|
||||
pactInterferenceMitigation: pu.pactInterference || 0,
|
||||
memorySlots: 3 + (pu.deepMemory || 0),
|
||||
log: ['✨ A new loop begins. Your insight grows...', '✨ The loop begins. You start with Mana Bolt.'],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Need to import these
|
||||
import { ELEMENTS, SPELLS_DEF } from '../constants';
|
||||
import { getFloorMaxHP, calcInsight } from './computed';
|
||||
Executable
+346
@@ -0,0 +1,346 @@
|
||||
// ─── Skill Slice ──────────────────────────────────────────────────────────────
|
||||
// Manages skills, studying, and skill progress
|
||||
|
||||
import type { StateCreator } from 'zustand';
|
||||
import type { GameState, StudyTarget, SkillUpgradeChoice } from '../types';
|
||||
import { SKILLS_DEF, SPELLS_DEF, getStudySpeedMultiplier, getStudyCostMultiplier } from '../constants';
|
||||
import { getUpgradesForSkillAtMilestone, getNextTierSkill, getTierMultiplier } from '../skill-evolution';
|
||||
import { computeEffects } from '../upgrade-effects';
|
||||
|
||||
export interface SkillSlice {
|
||||
// State
|
||||
skills: Record<string, number>;
|
||||
skillProgress: Record<string, number>;
|
||||
skillUpgrades: Record<string, string[]>;
|
||||
skillTiers: Record<string, number>;
|
||||
currentStudyTarget: StudyTarget | null;
|
||||
parallelStudyTarget: StudyTarget | null;
|
||||
|
||||
// Actions
|
||||
startStudyingSkill: (skillId: string) => void;
|
||||
startStudyingSpell: (spellId: string) => void;
|
||||
startParallelStudySkill: (skillId: string) => void;
|
||||
cancelStudy: () => void;
|
||||
cancelParallelStudy: () => void;
|
||||
selectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
deselectSkillUpgrade: (skillId: string, upgradeId: string) => void;
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[], milestone: 5 | 10) => void;
|
||||
tierUpSkill: (skillId: string) => void;
|
||||
|
||||
// Getters
|
||||
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => { available: SkillUpgradeChoice[]; selected: string[] };
|
||||
}
|
||||
|
||||
export const createSkillSlice = (
|
||||
set: StateCreator<GameState>['set'],
|
||||
get: () => GameState
|
||||
): SkillSlice => ({
|
||||
skills: {},
|
||||
skillProgress: {},
|
||||
skillUpgrades: {},
|
||||
skillTiers: {},
|
||||
currentStudyTarget: null,
|
||||
parallelStudyTarget: null,
|
||||
|
||||
startStudyingSkill: (skillId: string) => {
|
||||
const state = get();
|
||||
const sk = SKILLS_DEF[skillId];
|
||||
if (!sk) return;
|
||||
|
||||
const currentLevel = state.skills[skillId] || 0;
|
||||
if (currentLevel >= sk.max) return;
|
||||
|
||||
// Check prerequisites
|
||||
if (sk.req) {
|
||||
for (const [r, rl] of Object.entries(sk.req)) {
|
||||
if ((state.skills[r] || 0) < rl) return;
|
||||
}
|
||||
}
|
||||
|
||||
const costMult = getStudyCostMultiplier(state.skills);
|
||||
const totalCost = Math.floor(sk.base * (currentLevel + 1) * costMult);
|
||||
const manaCostPerHour = totalCost / sk.studyTime;
|
||||
|
||||
set({
|
||||
currentAction: 'study',
|
||||
currentStudyTarget: {
|
||||
type: 'skill',
|
||||
id: skillId,
|
||||
progress: state.skillProgress[skillId] || 0,
|
||||
required: sk.studyTime,
|
||||
manaCostPerHour,
|
||||
},
|
||||
log: [`📚 Started studying ${sk.name}... (${Math.floor(manaCostPerHour)} mana/hour)`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
startStudyingSpell: (spellId: string) => {
|
||||
const state = get();
|
||||
const sp = SPELLS_DEF[spellId];
|
||||
if (!sp || state.spells[spellId]?.learned) return;
|
||||
|
||||
const costMult = getStudyCostMultiplier(state.skills);
|
||||
const totalCost = Math.floor(sp.unlock * costMult);
|
||||
const studyTime = sp.studyTime || (sp.tier * 4);
|
||||
const manaCostPerHour = totalCost / studyTime;
|
||||
|
||||
set({
|
||||
currentAction: 'study',
|
||||
currentStudyTarget: {
|
||||
type: 'spell',
|
||||
id: spellId,
|
||||
progress: state.spells[spellId]?.studyProgress || 0,
|
||||
required: studyTime,
|
||||
manaCostPerHour,
|
||||
},
|
||||
spells: {
|
||||
...state.spells,
|
||||
[spellId]: {
|
||||
...(state.spells[spellId] || { learned: false, level: 0 }),
|
||||
studyProgress: state.spells[spellId]?.studyProgress || 0,
|
||||
},
|
||||
},
|
||||
log: [`📚 Started studying ${sp.name}... (${Math.floor(manaCostPerHour)} mana/hour)`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
startParallelStudySkill: (skillId: string) => {
|
||||
const state = get();
|
||||
if (state.parallelStudyTarget) return;
|
||||
if (!state.currentStudyTarget) return;
|
||||
|
||||
const sk = SKILLS_DEF[skillId];
|
||||
if (!sk) return;
|
||||
|
||||
const currentLevel = state.skills[skillId] || 0;
|
||||
if (currentLevel >= sk.max) return;
|
||||
|
||||
if (state.currentStudyTarget.id === skillId) return;
|
||||
|
||||
set({
|
||||
parallelStudyTarget: {
|
||||
type: 'skill',
|
||||
id: skillId,
|
||||
progress: state.skillProgress[skillId] || 0,
|
||||
required: sk.studyTime,
|
||||
manaCostPerHour: 0, // Parallel study doesn't cost extra
|
||||
},
|
||||
log: [`📚 Started parallel study of ${sk.name}... (50% speed)`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
cancelStudy: () => {
|
||||
const state = get();
|
||||
if (!state.currentStudyTarget) return;
|
||||
|
||||
const savedProgress = state.currentStudyTarget.progress;
|
||||
const log = ['📖 Study paused. Progress saved.', ...state.log.slice(0, 49)];
|
||||
|
||||
if (state.currentStudyTarget.type === 'skill') {
|
||||
set({
|
||||
currentStudyTarget: null,
|
||||
currentAction: 'meditate',
|
||||
skillProgress: {
|
||||
...state.skillProgress,
|
||||
[state.currentStudyTarget.id]: savedProgress,
|
||||
},
|
||||
log,
|
||||
});
|
||||
} else {
|
||||
set({
|
||||
currentStudyTarget: null,
|
||||
currentAction: 'meditate',
|
||||
spells: {
|
||||
...state.spells,
|
||||
[state.currentStudyTarget.id]: {
|
||||
...(state.spells[state.currentStudyTarget.id] || { learned: false, level: 0 }),
|
||||
studyProgress: savedProgress,
|
||||
},
|
||||
},
|
||||
log,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
cancelParallelStudy: () => {
|
||||
set((state) => {
|
||||
if (!state.parallelStudyTarget) return state;
|
||||
return {
|
||||
parallelStudyTarget: null,
|
||||
log: ['📖 Parallel study cancelled.', ...state.log.slice(0, 49)],
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
selectSkillUpgrade: (skillId: string, upgradeId: string) => {
|
||||
set((state) => {
|
||||
const current = state.skillUpgrades?.[skillId] || [];
|
||||
if (current.includes(upgradeId)) return state;
|
||||
if (current.length >= 2) return state;
|
||||
return {
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[skillId]: [...current, upgradeId],
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
deselectSkillUpgrade: (skillId: string, upgradeId: string) => {
|
||||
set((state) => {
|
||||
const current = state.skillUpgrades?.[skillId] || [];
|
||||
return {
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[skillId]: current.filter(id => id !== upgradeId),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
commitSkillUpgrades: (skillId: string, upgradeIds: string[], milestone: 5 | 10) => {
|
||||
set((state) => {
|
||||
const existingUpgrades = state.skillUpgrades?.[skillId] || [];
|
||||
const otherMilestoneUpgrades = existingUpgrades.filter(
|
||||
id => milestone === 5 ? id.includes('_l10') : id.includes('_l5')
|
||||
);
|
||||
return {
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[skillId]: [...otherMilestoneUpgrades, ...upgradeIds],
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
tierUpSkill: (skillId: string) => {
|
||||
const state = get();
|
||||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||
const currentTier = state.skillTiers?.[baseSkillId] || 1;
|
||||
const nextTier = currentTier + 1;
|
||||
|
||||
if (nextTier > 5) return;
|
||||
|
||||
const nextTierSkillId = `${baseSkillId}_t${nextTier}`;
|
||||
|
||||
set({
|
||||
skillTiers: {
|
||||
...state.skillTiers,
|
||||
[baseSkillId]: nextTier,
|
||||
},
|
||||
skills: {
|
||||
...state.skills,
|
||||
[nextTierSkillId]: 0,
|
||||
[skillId]: 0,
|
||||
},
|
||||
skillProgress: {
|
||||
...state.skillProgress,
|
||||
[skillId]: 0,
|
||||
[nextTierSkillId]: 0,
|
||||
},
|
||||
skillUpgrades: {
|
||||
...state.skillUpgrades,
|
||||
[nextTierSkillId]: [],
|
||||
[skillId]: [],
|
||||
},
|
||||
log: [`🌟 ${SKILLS_DEF[baseSkillId]?.name || baseSkillId} evolved to Tier ${nextTier}!`, ...state.log.slice(0, 49)],
|
||||
});
|
||||
},
|
||||
|
||||
getSkillUpgradeChoices: (skillId: string, milestone: 5 | 10) => {
|
||||
const state = get();
|
||||
const baseSkillId = skillId.includes('_t') ? skillId.split('_t')[0] : skillId;
|
||||
const tier = state.skillTiers?.[baseSkillId] || 1;
|
||||
|
||||
const available = getUpgradesForSkillAtMilestone(skillId, milestone, state.skillTiers || {});
|
||||
const selected = (state.skillUpgrades?.[skillId] || []).filter(id =>
|
||||
available.some(u => u.id === id)
|
||||
);
|
||||
|
||||
return { available, selected };
|
||||
},
|
||||
});
|
||||
|
||||
// Process study progress (called during tick)
|
||||
export function processStudy(state: GameState, deltaHours: number): Partial<GameState> {
|
||||
if (state.currentAction !== 'study' || !state.currentStudyTarget) return {};
|
||||
|
||||
const target = state.currentStudyTarget;
|
||||
const studySpeedMult = getStudySpeedMultiplier(state.skills);
|
||||
const progressGain = deltaHours * studySpeedMult;
|
||||
const manaCost = progressGain * target.manaCostPerHour;
|
||||
|
||||
let rawMana = state.rawMana;
|
||||
let totalManaGathered = state.totalManaGathered;
|
||||
let skills = state.skills;
|
||||
let skillProgress = state.skillProgress;
|
||||
let spells = state.spells;
|
||||
const log = [...state.log];
|
||||
|
||||
if (rawMana >= manaCost) {
|
||||
rawMana -= manaCost;
|
||||
totalManaGathered += manaCost;
|
||||
|
||||
const newProgress = target.progress + progressGain;
|
||||
|
||||
if (newProgress >= target.required) {
|
||||
// Study complete
|
||||
if (target.type === 'skill') {
|
||||
const skillId = target.id;
|
||||
const currentLevel = skills[skillId] || 0;
|
||||
skills = { ...skills, [skillId]: currentLevel + 1 };
|
||||
skillProgress = { ...skillProgress, [skillId]: 0 };
|
||||
log.unshift(`✅ ${SKILLS_DEF[skillId]?.name} Lv.${currentLevel + 1} mastered!`);
|
||||
} else if (target.type === 'spell') {
|
||||
const spellId = target.id;
|
||||
spells = {
|
||||
...spells,
|
||||
[spellId]: { learned: true, level: 1, studyProgress: 0 },
|
||||
};
|
||||
log.unshift(`📖 ${SPELLS_DEF[spellId]?.name} learned!`);
|
||||
}
|
||||
|
||||
return {
|
||||
rawMana,
|
||||
totalManaGathered,
|
||||
skills,
|
||||
skillProgress,
|
||||
spells,
|
||||
currentStudyTarget: null,
|
||||
currentAction: 'meditate',
|
||||
log,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
rawMana,
|
||||
totalManaGathered,
|
||||
currentStudyTarget: { ...target, progress: newProgress },
|
||||
};
|
||||
}
|
||||
|
||||
// Not enough mana
|
||||
log.unshift('⚠️ Not enough mana to continue studying. Progress saved.');
|
||||
|
||||
if (target.type === 'skill') {
|
||||
return {
|
||||
currentStudyTarget: null,
|
||||
currentAction: 'meditate',
|
||||
skillProgress: { ...skillProgress, [target.id]: target.progress },
|
||||
log,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
currentStudyTarget: null,
|
||||
currentAction: 'meditate',
|
||||
spells: {
|
||||
...spells,
|
||||
[target.id]: {
|
||||
...(spells[target.id] || { learned: false, level: 0 }),
|
||||
studyProgress: target.progress,
|
||||
},
|
||||
},
|
||||
log,
|
||||
};
|
||||
}
|
||||
}
|
||||
Executable
+82
@@ -0,0 +1,82 @@
|
||||
// ─── Time Slice ───────────────────────────────────────────────────────────────
|
||||
// Manages game time, loops, and game state
|
||||
|
||||
import type { StateCreator } from 'zustand';
|
||||
import type { GameState } from '../types';
|
||||
import { MAX_DAY } from '../constants';
|
||||
import { calcInsight } from './computed';
|
||||
|
||||
export interface TimeSlice {
|
||||
// State
|
||||
day: number;
|
||||
hour: number;
|
||||
loopCount: number;
|
||||
gameOver: boolean;
|
||||
victory: boolean;
|
||||
paused: boolean;
|
||||
incursionStrength: number;
|
||||
loopInsight: number;
|
||||
log: string[];
|
||||
|
||||
// Actions
|
||||
togglePause: () => void;
|
||||
resetGame: () => void;
|
||||
startNewLoop: () => void;
|
||||
addLog: (message: string) => void;
|
||||
}
|
||||
|
||||
export const createTimeSlice = (
|
||||
set: StateCreator<GameState>['set'],
|
||||
get: () => GameState,
|
||||
initialOverrides?: Partial<GameState>
|
||||
): TimeSlice => ({
|
||||
day: 1,
|
||||
hour: 0,
|
||||
loopCount: initialOverrides?.loopCount || 0,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
paused: false,
|
||||
incursionStrength: 0,
|
||||
loopInsight: 0,
|
||||
log: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'],
|
||||
|
||||
togglePause: () => {
|
||||
set((state) => ({ paused: !state.paused }));
|
||||
},
|
||||
|
||||
resetGame: () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('mana-loop-storage');
|
||||
}
|
||||
// Reset to initial state
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
startNewLoop: () => {
|
||||
const state = get();
|
||||
const insightGained = state.loopInsight || calcInsight(state);
|
||||
const total = state.insight + insightGained;
|
||||
|
||||
// Spell preservation is handled through the prestige upgrade "spellMemory"
|
||||
// which is purchased with insight
|
||||
|
||||
// This will be handled by the main store reset
|
||||
set({
|
||||
day: 1,
|
||||
hour: 0,
|
||||
gameOver: false,
|
||||
victory: false,
|
||||
loopCount: state.loopCount + 1,
|
||||
insight: total,
|
||||
totalInsight: (state.totalInsight || 0) + insightGained,
|
||||
loopInsight: 0,
|
||||
log: ['✨ A new loop begins. Your insight grows...'],
|
||||
});
|
||||
},
|
||||
|
||||
addLog: (message: string) => {
|
||||
set((state) => ({
|
||||
log: [message, ...state.log.slice(0, 49)],
|
||||
}));
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user