Some checks failed
Build and Publish Mana Loop Docker Image / build-and-publish (push) Has been cancelled
Major changes: - Created docs/skills.md with comprehensive skill system documentation - Rewrote skill-evolution.ts with new upgrade tree structure: - Upgrades organized in branching paths with prerequisites - Each choice can lead to upgraded versions at future milestones - Support for upgrade children and requirement chains - Added getBaseSkillId and generateTierSkillDef helper functions - Fixed getFloorElement to use FLOOR_ELEM_CYCLE.length - Updated test files to match current skill definitions - Removed tests for non-existent skills Skill system now supports: - Levels 1-10 for most skills, level 5 caps for specialized, level 1 for research - Tier up system: Tier N Level 1 = Tier N-1 Level 10 in power - Milestone upgrades at levels 5 and 10 with branching upgrade trees - Attunement requirements for skill access and tier up - Study costs and time for leveling
510 lines
18 KiB
TypeScript
Executable File
510 lines
18 KiB
TypeScript
Executable File
// ─── Game Store (Coordinator) ─────────────────────────────────────────────────
|
|
// Manages: day, hour, incursionStrength, containmentWards
|
|
// Coordinates tick function across all stores
|
|
|
|
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
import { TICK_MS, HOURS_PER_TICK, MAX_DAY, SPELLS_DEF, GUARDIANS, ELEMENTS, BASE_UNLOCKED_ELEMENTS, getStudySpeedMultiplier } from '../constants';
|
|
import { computeEffects, hasSpecial, SPECIAL_EFFECTS } from '../upgrade-effects';
|
|
import {
|
|
computeMaxMana,
|
|
computeRegen,
|
|
getFloorElement,
|
|
getFloorMaxHP,
|
|
getMeditationBonus,
|
|
getIncursionStrength,
|
|
calcInsight,
|
|
calcDamage,
|
|
deductSpellCost,
|
|
} from '../utils';
|
|
import { useUIStore } from './uiStore';
|
|
import { usePrestigeStore } from './prestigeStore';
|
|
import { useManaStore } from './manaStore';
|
|
import { useSkillStore } from './skillStore';
|
|
import { useCombatStore, makeInitialSpells } from './combatStore';
|
|
import type { Memory } from '../types';
|
|
|
|
export interface GameCoordinatorState {
|
|
day: number;
|
|
hour: number;
|
|
incursionStrength: number;
|
|
containmentWards: number;
|
|
initialized: boolean;
|
|
}
|
|
|
|
export interface GameCoordinatorStore extends GameCoordinatorState {
|
|
tick: () => void;
|
|
resetGame: () => void;
|
|
togglePause: () => void;
|
|
startNewLoop: () => void;
|
|
gatherMana: () => void;
|
|
initGame: () => void;
|
|
}
|
|
|
|
const initialState: GameCoordinatorState = {
|
|
day: 1,
|
|
hour: 0,
|
|
incursionStrength: 0,
|
|
containmentWards: 0,
|
|
initialized: false,
|
|
};
|
|
|
|
// Helper function for checking spell cost affordability
|
|
function canAffordSpell(
|
|
cost: { type: string; 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 if (cost.element) {
|
|
const elem = elements[cost.element];
|
|
return elem && elem.unlocked && elem.current >= cost.amount;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export const useGameStore = create<GameCoordinatorStore>()(
|
|
persist(
|
|
(set, get) => ({
|
|
...initialState,
|
|
|
|
initGame: () => {
|
|
set({ initialized: true });
|
|
},
|
|
|
|
tick: () => {
|
|
const uiState = useUIStore.getState();
|
|
if (uiState.gameOver || uiState.paused) return;
|
|
|
|
// Helper for logging
|
|
const addLog = (msg: string) => useUIStore.getState().addLog(msg);
|
|
|
|
// Get all store states
|
|
const prestigeState = usePrestigeStore.getState();
|
|
const manaState = useManaStore.getState();
|
|
const skillState = useSkillStore.getState();
|
|
const combatState = useCombatStore.getState();
|
|
|
|
// Compute effects from upgrades
|
|
const effects = computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {});
|
|
|
|
const maxMana = computeMaxMana(
|
|
{ skills: skillState.skills, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: skillState.skillUpgrades, skillTiers: skillState.skillTiers },
|
|
effects
|
|
);
|
|
const baseRegen = computeRegen(
|
|
{ skills: skillState.skills, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: skillState.skillUpgrades, skillTiers: skillState.skillTiers },
|
|
effects
|
|
);
|
|
|
|
// Time progression
|
|
let hour = get().hour + HOURS_PER_TICK;
|
|
let day = get().day;
|
|
if (hour >= 24) {
|
|
hour -= 24;
|
|
day += 1;
|
|
}
|
|
|
|
// Check for loop end
|
|
if (day > MAX_DAY) {
|
|
const insightGained = calcInsight({
|
|
maxFloorReached: combatState.maxFloorReached,
|
|
totalManaGathered: manaState.totalManaGathered,
|
|
signedPacts: prestigeState.signedPacts,
|
|
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
|
skills: skillState.skills,
|
|
});
|
|
|
|
addLog(`⏰ The loop ends. Gained ${insightGained} Insight.`);
|
|
useUIStore.getState().setGameOver(true, false);
|
|
usePrestigeStore.getState().setLoopInsight(insightGained);
|
|
set({ day, hour });
|
|
return;
|
|
}
|
|
|
|
// Check for victory
|
|
if (combatState.maxFloorReached >= 100 && prestigeState.signedPacts.includes(100)) {
|
|
const insightGained = calcInsight({
|
|
maxFloorReached: combatState.maxFloorReached,
|
|
totalManaGathered: manaState.totalManaGathered,
|
|
signedPacts: prestigeState.signedPacts,
|
|
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
|
skills: skillState.skills,
|
|
}) * 3;
|
|
|
|
addLog(`🏆 VICTORY! The Awakened One falls! Gained ${insightGained} Insight!`);
|
|
useUIStore.getState().setGameOver(true, true);
|
|
usePrestigeStore.getState().setLoopInsight(insightGained);
|
|
return;
|
|
}
|
|
|
|
// Incursion
|
|
const incursionStrength = getIncursionStrength(day, hour);
|
|
|
|
// Meditation bonus tracking and regen calculation
|
|
let meditateTicks = manaState.meditateTicks;
|
|
let meditationMultiplier = 1;
|
|
|
|
if (combatState.currentAction === 'meditate') {
|
|
meditateTicks++;
|
|
meditationMultiplier = getMeditationBonus(meditateTicks, skillState.skills, effects.meditationEfficiency);
|
|
} else {
|
|
meditateTicks = 0;
|
|
}
|
|
|
|
// Calculate effective regen with incursion and meditation
|
|
const effectiveRegen = baseRegen * (1 - incursionStrength) * meditationMultiplier;
|
|
|
|
// Mana regeneration
|
|
let rawMana = Math.min(manaState.rawMana + effectiveRegen * HOURS_PER_TICK, maxMana);
|
|
let totalManaGathered = manaState.totalManaGathered;
|
|
let elements = { ...manaState.elements };
|
|
|
|
// Study progress - handled by skillStore
|
|
if (combatState.currentAction === 'study' && skillState.currentStudyTarget) {
|
|
const studySpeedMult = getStudySpeedMultiplier(skillState.skills);
|
|
const progressGain = HOURS_PER_TICK * studySpeedMult;
|
|
|
|
const result = useSkillStore.getState().updateStudyProgress(progressGain);
|
|
|
|
if (result.completed && result.target) {
|
|
if (result.target.type === 'skill') {
|
|
const skillId = result.target.id;
|
|
const currentLevel = skillState.skills[skillId] || 0;
|
|
// Update skill level
|
|
useSkillStore.getState().incrementSkillLevel(skillId);
|
|
useSkillStore.getState().clearPaidStudySkill(skillId);
|
|
useCombatStore.getState().setAction('meditate');
|
|
addLog(`✅ ${skillId} Lv.${currentLevel + 1} mastered!`);
|
|
} else if (result.target.type === 'spell') {
|
|
const spellId = result.target.id;
|
|
useCombatStore.getState().learnSpell(spellId);
|
|
useSkillStore.getState().setCurrentStudyTarget(null);
|
|
useCombatStore.getState().setAction('meditate');
|
|
addLog(`📖 ${SPELLS_DEF[spellId]?.name || spellId} learned!`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert action - auto convert mana
|
|
if (combatState.currentAction === 'convert') {
|
|
const unlockedElements = Object.entries(elements)
|
|
.filter(([, e]) => e.unlocked && e.current < e.max);
|
|
|
|
if (unlockedElements.length > 0 && rawMana >= 100) {
|
|
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
|
|
);
|
|
if (canConvert > 0) {
|
|
rawMana -= canConvert * 100;
|
|
elements = {
|
|
...elements,
|
|
[targetId]: { ...targetState, current: targetState.current + canConvert }
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pact ritual progress
|
|
if (prestigeState.pactRitualFloor !== null) {
|
|
const guardian = GUARDIANS[prestigeState.pactRitualFloor];
|
|
if (guardian) {
|
|
const pactAffinityBonus = 1 - (prestigeState.prestigeUpgrades.pactAffinity || 0) * 0.1;
|
|
const requiredTime = guardian.pactTime * pactAffinityBonus;
|
|
const newProgress = prestigeState.pactRitualProgress + HOURS_PER_TICK;
|
|
|
|
if (newProgress >= requiredTime) {
|
|
addLog(`📜 Pact signed with ${guardian.name}! You have gained their boons.`);
|
|
usePrestigeStore.getState().addSignedPact(prestigeState.pactRitualFloor);
|
|
usePrestigeStore.getState().removeDefeatedGuardian(prestigeState.pactRitualFloor);
|
|
usePrestigeStore.getState().setPactRitualFloor(null);
|
|
} else {
|
|
usePrestigeStore.getState().updatePactRitualProgress(newProgress);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Combat
|
|
let { currentFloor, floorHP, floorMaxHP, maxFloorReached, castProgress } = combatState;
|
|
const floorElement = getFloorElement(currentFloor);
|
|
|
|
if (combatState.currentAction === 'climb') {
|
|
const spellId = combatState.activeSpell;
|
|
const spellDef = SPELLS_DEF[spellId];
|
|
|
|
if (spellDef) {
|
|
const baseAttackSpeed = 1 + (skillState.skills.quickCast || 0) * 0.05;
|
|
const totalAttackSpeed = baseAttackSpeed * effects.attackSpeedMultiplier;
|
|
const spellCastSpeed = spellDef.castSpeed || 1;
|
|
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
|
|
|
|
castProgress = (castProgress || 0) + progressPerTick;
|
|
|
|
// Process complete casts
|
|
while (castProgress >= 1 && canAffordSpell(spellDef.cost, rawMana, elements)) {
|
|
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
|
|
rawMana = afterCost.rawMana;
|
|
elements = afterCost.elements;
|
|
totalManaGathered += spellDef.cost.amount;
|
|
|
|
// Calculate damage
|
|
let dmg = calcDamage(
|
|
{ skills: skillState.skills, signedPacts: prestigeState.signedPacts },
|
|
spellId,
|
|
floorElement
|
|
);
|
|
|
|
// Apply upgrade damage multipliers and bonuses
|
|
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
|
|
if (hasSpecial(effects, SPECIAL_EFFECTS.BERSERKER) && rawMana < maxMana * 0.5) {
|
|
dmg *= 1.5;
|
|
}
|
|
|
|
// Spell echo - chance to cast again
|
|
const echoChance = (skillState.skills.spellEcho || 0) * 0.1;
|
|
if (Math.random() < echoChance) {
|
|
dmg *= 2;
|
|
addLog(`✨ Spell Echo! Double damage!`);
|
|
}
|
|
|
|
// Apply damage
|
|
floorHP = Math.max(0, floorHP - dmg);
|
|
castProgress -= 1;
|
|
|
|
if (floorHP <= 0) {
|
|
// Floor cleared
|
|
const wasGuardian = GUARDIANS[currentFloor];
|
|
if (wasGuardian && !prestigeState.defeatedGuardians.includes(currentFloor) && !prestigeState.signedPacts.includes(currentFloor)) {
|
|
usePrestigeStore.getState().addDefeatedGuardian(currentFloor);
|
|
addLog(`⚔️ ${wasGuardian.name} defeated! Visit the Grimoire to sign a pact.`);
|
|
} else if (!wasGuardian) {
|
|
if (currentFloor % 5 === 0) {
|
|
addLog(`🏰 Floor ${currentFloor} cleared!`);
|
|
}
|
|
}
|
|
|
|
currentFloor = currentFloor + 1;
|
|
if (currentFloor > 100) {
|
|
currentFloor = 100;
|
|
}
|
|
floorMaxHP = getFloorMaxHP(currentFloor);
|
|
floorHP = floorMaxHP;
|
|
maxFloorReached = Math.max(maxFloorReached, currentFloor);
|
|
castProgress = 0;
|
|
|
|
useCombatStore.getState().advanceFloor();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update all stores with new state
|
|
useManaStore.setState({
|
|
rawMana,
|
|
meditateTicks,
|
|
totalManaGathered,
|
|
elements,
|
|
});
|
|
|
|
useCombatStore.setState({
|
|
floorHP,
|
|
floorMaxHP,
|
|
maxFloorReached,
|
|
castProgress,
|
|
});
|
|
|
|
set({
|
|
day,
|
|
hour,
|
|
incursionStrength,
|
|
});
|
|
},
|
|
|
|
gatherMana: () => {
|
|
const skillState = useSkillStore.getState();
|
|
const manaState = useManaStore.getState();
|
|
const prestigeState = usePrestigeStore.getState();
|
|
|
|
// Compute click mana
|
|
let cm = 1 +
|
|
(skillState.skills.manaTap || 0) * 1 +
|
|
(skillState.skills.manaSurge || 0) * 3;
|
|
|
|
// Mana overflow bonus
|
|
const overflowBonus = 1 + (skillState.skills.manaOverflow || 0) * 0.25;
|
|
cm = Math.floor(cm * overflowBonus);
|
|
|
|
const effects = computeEffects(skillState.skillUpgrades || {}, skillState.skillTiers || {});
|
|
const max = computeMaxMana(
|
|
{ skills: skillState.skills, prestigeUpgrades: prestigeState.prestigeUpgrades, skillUpgrades: skillState.skillUpgrades, skillTiers: skillState.skillTiers },
|
|
effects
|
|
);
|
|
|
|
useManaStore.setState({
|
|
rawMana: Math.min(manaState.rawMana + cm, max),
|
|
totalManaGathered: manaState.totalManaGathered + cm,
|
|
});
|
|
},
|
|
|
|
resetGame: () => {
|
|
// Clear all persisted state
|
|
localStorage.removeItem('mana-loop-ui-storage');
|
|
localStorage.removeItem('mana-loop-prestige-storage');
|
|
localStorage.removeItem('mana-loop-mana-storage');
|
|
localStorage.removeItem('mana-loop-skill-storage');
|
|
localStorage.removeItem('mana-loop-combat-storage');
|
|
localStorage.removeItem('mana-loop-game-storage');
|
|
|
|
const startFloor = 1;
|
|
const elemMax = 10;
|
|
|
|
const elements: Record<string, { current: number; max: number; unlocked: boolean }> = {};
|
|
Object.keys(ELEMENTS).forEach((k) => {
|
|
elements[k] = {
|
|
current: 0,
|
|
max: elemMax,
|
|
unlocked: BASE_UNLOCKED_ELEMENTS.includes(k),
|
|
};
|
|
});
|
|
|
|
useUIStore.getState().resetUI();
|
|
usePrestigeStore.getState().resetPrestige();
|
|
useManaStore.getState().resetMana({}, {}, {}, {});
|
|
useSkillStore.getState().resetSkills();
|
|
useCombatStore.getState().resetCombat(startFloor);
|
|
|
|
set({
|
|
...initialState,
|
|
initialized: true,
|
|
});
|
|
},
|
|
|
|
togglePause: () => {
|
|
useUIStore.getState().togglePause();
|
|
},
|
|
|
|
startNewLoop: () => {
|
|
const prestigeState = usePrestigeStore.getState();
|
|
const combatState = useCombatStore.getState();
|
|
const manaState = useManaStore.getState();
|
|
const skillState = useSkillStore.getState();
|
|
|
|
const insightGained = prestigeState.loopInsight || calcInsight({
|
|
maxFloorReached: combatState.maxFloorReached,
|
|
totalManaGathered: manaState.totalManaGathered,
|
|
signedPacts: prestigeState.signedPacts,
|
|
prestigeUpgrades: prestigeState.prestigeUpgrades,
|
|
skills: skillState.skills,
|
|
});
|
|
|
|
const total = prestigeState.insight + insightGained;
|
|
|
|
// Spell preservation is only through prestige upgrade "spellMemory" (purchased with insight)
|
|
// Not through a skill - that would undermine the insight economy
|
|
|
|
const pu = prestigeState.prestigeUpgrades;
|
|
const startFloor = 1 + (pu.spireKey || 0) * 2;
|
|
|
|
// Apply saved memories - restore skill levels, tiers, and upgrades
|
|
const memories = prestigeState.memories || [];
|
|
const newSkills: Record<string, number> = {};
|
|
const newSkillTiers: Record<string, number> = {};
|
|
const newSkillUpgrades: Record<string, string[]> = {};
|
|
|
|
if (memories.length > 0) {
|
|
for (const memory of memories) {
|
|
const tieredSkillId = memory.tier > 1 ? `${memory.skillId}_t${memory.tier}` : memory.skillId;
|
|
newSkills[tieredSkillId] = memory.level;
|
|
|
|
if (memory.tier > 1) {
|
|
newSkillTiers[memory.skillId] = memory.tier;
|
|
}
|
|
|
|
newSkillUpgrades[tieredSkillId] = memory.upgrades || [];
|
|
}
|
|
}
|
|
|
|
// Reset and update all stores for new loop
|
|
useUIStore.setState({
|
|
gameOver: false,
|
|
victory: false,
|
|
paused: false,
|
|
logs: ['✨ The loop begins. You start with Mana Bolt. Gather your strength, mage.'],
|
|
});
|
|
|
|
usePrestigeStore.getState().resetPrestigeForNewLoop(
|
|
total,
|
|
pu,
|
|
prestigeState.memories,
|
|
3 + (pu.deepMemory || 0)
|
|
);
|
|
usePrestigeStore.getState().incrementLoopCount();
|
|
|
|
useManaStore.getState().resetMana(pu, newSkills, newSkillUpgrades, newSkillTiers);
|
|
|
|
useSkillStore.getState().resetSkills(newSkills, newSkillUpgrades, newSkillTiers);
|
|
|
|
// Reset combat with starting floor and any spells from prestige upgrades
|
|
const startSpells = makeInitialSpells();
|
|
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++) {
|
|
startSpells[shuffled[i]] = { learned: true, level: 1, studyProgress: 0 };
|
|
}
|
|
}
|
|
|
|
useCombatStore.setState({
|
|
currentFloor: startFloor,
|
|
floorHP: getFloorMaxHP(startFloor),
|
|
floorMaxHP: getFloorMaxHP(startFloor),
|
|
maxFloorReached: startFloor,
|
|
activeSpell: 'manaBolt',
|
|
currentAction: 'meditate',
|
|
castProgress: 0,
|
|
spells: startSpells,
|
|
});
|
|
|
|
set({
|
|
day: 1,
|
|
hour: 0,
|
|
incursionStrength: 0,
|
|
containmentWards: 0,
|
|
});
|
|
},
|
|
}),
|
|
{
|
|
name: 'mana-loop-game-storage',
|
|
partialize: (state) => ({
|
|
day: state.day,
|
|
hour: state.hour,
|
|
incursionStrength: state.incursionStrength,
|
|
containmentWards: state.containmentWards,
|
|
}),
|
|
}
|
|
)
|
|
);
|
|
|
|
// Re-export the game loop hook for convenience
|
|
export function useGameLoop() {
|
|
const tick = useGameStore((s) => s.tick);
|
|
|
|
return {
|
|
start: () => {
|
|
const interval = setInterval(tick, TICK_MS);
|
|
return () => clearInterval(interval);
|
|
},
|
|
};
|
|
}
|