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
347 lines
10 KiB
TypeScript
Executable File
347 lines
10 KiB
TypeScript
Executable File
// ─── 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,
|
|
};
|
|
}
|
|
}
|