Fix study system: per-hour mana cost instead of upfront, fix game time freeze bug, improve mobile UI
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m43s

This commit is contained in:
2026-03-27 09:33:48 +00:00
parent a5e37b9b24
commit 2f407071a4
6 changed files with 197 additions and 119 deletions

View File

@@ -4,6 +4,28 @@ This document provides a comprehensive overview of the project architecture for
--- ---
## 🔑 Git Credentials (SAVE THESE)
**Repository:** `git@gitea.tailf367e3.ts.net:Anexim/Mana-Loop.git`
**HTTPS URL with credentials:**
```
https://zhipu:5LlnutmdsC2WirDwWgnZuRH7@gitea.tailf367e3.ts.net/Anexim/Mana-Loop.git
```
**Credentials:**
- **User:** zhipu
- **Email:** zhipu@local.local
- **Password:** 5LlnutmdsC2WirDwWgnZuRH7
**To configure git:**
```bash
git config --global user.name "zhipu"
git config --global user.email "zhipu@local.local"
```
---
## ⚠️ MANDATORY GIT WORKFLOW - MUST BE FOLLOWED ## ⚠️ MANDATORY GIT WORKFLOW - MUST BE FOLLOWED
**Before starting ANY work, you MUST:** **Before starting ANY work, you MUST:**

View File

@@ -228,15 +228,15 @@ export default function ManaLoopGame() {
{/* Right Panel - Tabs */} {/* Right Panel - Tabs */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Tabs value={activeTab} onValueChange={setActiveTab}> <Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid grid-cols-8 w-full mb-4"> <TabsList className="flex flex-wrap gap-1 w-full mb-4 h-auto">
<TabsTrigger value="spire"> Spire</TabsTrigger> <TabsTrigger value="spire" className="text-xs px-2 py-1"> Spire</TabsTrigger>
<TabsTrigger value="skills">📚 Skills</TabsTrigger> <TabsTrigger value="skills" className="text-xs px-2 py-1">📚 Skills</TabsTrigger>
<TabsTrigger value="spells"> Spells</TabsTrigger> <TabsTrigger value="spells" className="text-xs px-2 py-1"> Spells</TabsTrigger>
<TabsTrigger value="equipment">🛡 Gear</TabsTrigger> <TabsTrigger value="equipment" className="text-xs px-2 py-1">🛡 Gear</TabsTrigger>
<TabsTrigger value="crafting">🔧 Craft</TabsTrigger> <TabsTrigger value="crafting" className="text-xs px-2 py-1">🔧 Craft</TabsTrigger>
<TabsTrigger value="lab">🔬 Lab</TabsTrigger> <TabsTrigger value="lab" className="text-xs px-2 py-1">🔬 Lab</TabsTrigger>
<TabsTrigger value="stats">📊 Stats</TabsTrigger> <TabsTrigger value="stats" className="text-xs px-2 py-1">📊 Stats</TabsTrigger>
<TabsTrigger value="grimoire">📖 Grimoire</TabsTrigger> <TabsTrigger value="grimoire" className="text-xs px-2 py-1">📖 Grimoire</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="spire"> <TabsContent value="spire">

View File

@@ -4,18 +4,20 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants'; import { MAX_DAY, INCURSION_START_DAY } from '@/lib/game/constants';
interface CalendarDisplayProps { interface CalendarDisplayProps {
currentDay: number; day: number;
hour: number;
incursionStrength?: number;
} }
export function CalendarDisplay({ currentDay }: CalendarDisplayProps) { export function CalendarDisplay({ day }: CalendarDisplayProps) {
const days: React.ReactElement[] = []; const days: React.ReactElement[] = [];
for (let d = 1; d <= MAX_DAY; d++) { for (let d = 1; d <= MAX_DAY; d++) {
let dayClass = 'w-7 h-7 rounded text-xs flex items-center justify-center font-mono border transition-all '; let dayClass = 'w-6 h-6 sm:w-7 sm:h-7 rounded text-xs flex items-center justify-center font-mono border transition-all ';
if (d < currentDay) { if (d < day) {
dayClass += 'bg-blue-900/30 border-blue-800/50 text-blue-400'; dayClass += 'bg-blue-900/30 border-blue-800/50 text-blue-400';
} else if (d === currentDay) { } else if (d === day) {
dayClass += 'bg-blue-600/40 border-blue-500 text-blue-300 shadow-lg shadow-blue-500/30'; dayClass += 'bg-blue-600/40 border-blue-500 text-blue-300 shadow-lg shadow-blue-500/30';
} else { } else {
dayClass += 'bg-gray-800/30 border-gray-700/50 text-gray-500'; dayClass += 'bg-gray-800/30 border-gray-700/50 text-gray-500';
@@ -40,5 +42,9 @@ export function CalendarDisplay({ currentDay }: CalendarDisplayProps) {
); );
} }
return <>{days}</>; return (
<div className="grid grid-cols-7 sm:grid-cols-7 md:grid-cols-14 gap-1">
{days}
</div>
);
} }

View File

@@ -410,6 +410,9 @@ export const useGameStore = create<GameStore>()(
let totalManaGathered = state.totalManaGathered; let totalManaGathered = state.totalManaGathered;
let elements = state.elements; let elements = state.elements;
// Increment total ticks early (needed for study tracking)
const newTotalTicks = state.totalTicks + 1;
// Study progress // Study progress
let currentStudyTarget = state.currentStudyTarget; let currentStudyTarget = state.currentStudyTarget;
let skills = state.skills; let skills = state.skills;
@@ -419,97 +422,130 @@ export const useGameStore = create<GameStore>()(
let unlockedEffects = state.unlockedEffects; let unlockedEffects = state.unlockedEffects;
let consecutiveStudyHours = state.consecutiveStudyHours || 0; let consecutiveStudyHours = state.consecutiveStudyHours || 0;
let studyStartedAt = state.studyStartedAt; let studyStartedAt = state.studyStartedAt;
let lastStudyCost = state.lastStudyCost;
if (state.currentAction === 'study' && currentStudyTarget) { if (state.currentAction === 'study' && currentStudyTarget) {
// Track when study started (for STUDY_RUSH) // Calculate mana cost for this tick
if (studyStartedAt === null) { const manaCostPerTick = (currentStudyTarget.manaCostPerHour || 0) * HOURS_PER_TICK;
studyStartedAt = newTotalTicks;
}
// Calculate study speed with all bonuses // Check if we have enough mana to continue studying
let studySpeedMult = getStudySpeedMultiplier(skills); if (rawMana < manaCostPerTick) {
// Not enough mana - pause study and save progress
// MENTAL_CLARITY: +10% study speed when mana > 75% const retentionBonus = 1 + (state.skills.knowledgeRetention || 0) * 0.2;
if (hasSpecial(effects, SPECIAL_EFFECTS.MENTAL_CLARITY) && state.rawMana >= maxMana * 0.75) { const savedProgress = Math.min(currentStudyTarget.progress, currentStudyTarget.required * retentionBonus);
studySpeedMult *= 1.1;
}
// STUDY_RUSH: First hour of study is 2x speed
const hoursStudied = (newTotalTicks - studyStartedAt) * HOURS_PER_TICK;
if (hasSpecial(effects, SPECIAL_EFFECTS.STUDY_RUSH) && hoursStudied < 1) {
studySpeedMult *= 2;
}
// STUDY_MOMENTUM: +5% study speed per consecutive hour (max +50%)
if (hasSpecial(effects, SPECIAL_EFFECTS.STUDY_MOMENTUM)) {
const momentumBonus = Math.min(0.5, consecutiveStudyHours * 0.05);
studySpeedMult *= 1 + momentumBonus;
}
const progressGain = HOURS_PER_TICK * studySpeedMult;
// KNOWLEDGE_ECHO: 10% instant study chance
let instantProgress = 0;
if (hasSpecial(effects, SPECIAL_EFFECTS.KNOWLEDGE_ECHO) && Math.random() < 0.1) {
instantProgress = currentStudyTarget.required - currentStudyTarget.progress;
log = [`⚡ Knowledge Echo! Instant study progress!`, ...log.slice(0, 49)];
}
currentStudyTarget = {
...currentStudyTarget,
progress: currentStudyTarget.progress + progressGain + instantProgress,
};
// Increment consecutive study hours
consecutiveStudyHours += HOURS_PER_TICK;
// Check if study is complete
if (currentStudyTarget.progress >= currentStudyTarget.required) {
// STUDY_REFUND: 25% mana back on study complete
if (hasSpecial(effects, SPECIAL_EFFECTS.STUDY_REFUND) && state.lastStudyCost > 0) {
const refund = Math.floor(state.lastStudyCost * 0.25);
rawMana = Math.min(rawMana + refund, maxMana);
log = [`💰 Study Refund! Recovered ${refund} mana!`, ...log.slice(0, 49)];
}
// Reset study tracking
studyStartedAt = null;
consecutiveStudyHours = 0;
if (currentStudyTarget.type === 'skill') { if (currentStudyTarget.type === 'skill') {
const skillId = currentStudyTarget.id; skillProgress = { ...skillProgress, [currentStudyTarget.id]: savedProgress };
const currentLevel = skills[skillId] || 0;
const newLevel = currentLevel + 1;
skills = { ...skills, [skillId]: newLevel };
skillProgress = { ...skillProgress, [skillId]: 0 };
log = [`${SKILLS_DEF[skillId]?.name} Lv.${newLevel} mastered!`, ...log.slice(0, 49)];
// Check if this skill unlocks effects (research skills)
const effectsToUnlock = EFFECT_RESEARCH_MAPPING[skillId];
if (effectsToUnlock && newLevel >= (SKILLS_DEF[skillId]?.max || 1)) {
const newEffects = effectsToUnlock.filter(e => !unlockedEffects.includes(e));
if (newEffects.length > 0) {
unlockedEffects = [...unlockedEffects, ...newEffects];
log = [`🔬 Unlocked ${newEffects.length} new enchantment effect(s)!`, ...log.slice(0, 49)];
}
}
// Special case: When enchanting skill reaches level 1, unlock mana bolt
if (skillId === 'enchanting' && newLevel >= 1) {
const enchantingEffects = ENCHANTING_UNLOCK_EFFECTS.filter(e => !unlockedEffects.includes(e));
if (enchantingEffects.length > 0) {
unlockedEffects = [...unlockedEffects, ...enchantingEffects];
log = [`✨ Enchantment design unlocked! Mana Bolt effect available.`, ...log.slice(0, 49)];
}
}
} else if (currentStudyTarget.type === 'spell') { } else if (currentStudyTarget.type === 'spell') {
// Spells can no longer be studied directly - they come from equipment spells = {
// This branch is kept for backward compatibility but should not be used ...spells,
const spellId = currentStudyTarget.id; [currentStudyTarget.id]: {
spells = { ...spells, [spellId]: { learned: true, level: 1, studyProgress: 0 } }; ...(spells[currentStudyTarget.id] || { learned: false, level: 0 }),
log = [`📖 ${SPELLS_DEF[spellId]?.name} learned!`, ...log.slice(0, 49)]; studyProgress: savedProgress,
},
};
} }
log = [`⚠️ Not enough mana to continue studying! Progress saved.`, ...log.slice(0, 49)];
currentStudyTarget = null; currentStudyTarget = null;
consecutiveStudyHours = 0;
studyStartedAt = null;
} else {
// Deduct mana for this tick of study
rawMana -= manaCostPerTick;
lastStudyCost = (lastStudyCost || 0) + manaCostPerTick;
// Track when study started (for STUDY_RUSH)
if (studyStartedAt === null) {
studyStartedAt = newTotalTicks;
}
// Calculate study speed with all bonuses
let studySpeedMult = getStudySpeedMultiplier(skills);
// MENTAL_CLARITY: +10% study speed when mana > 75%
if (hasSpecial(effects, SPECIAL_EFFECTS.MENTAL_CLARITY) && rawMana >= maxMana * 0.75) {
studySpeedMult *= 1.1;
}
// STUDY_RUSH: First hour of study is 2x speed
const hoursStudied = (newTotalTicks - studyStartedAt) * HOURS_PER_TICK;
if (hasSpecial(effects, SPECIAL_EFFECTS.STUDY_RUSH) && hoursStudied < 1) {
studySpeedMult *= 2;
}
// STUDY_MOMENTUM: +5% study speed per consecutive hour (max +50%)
if (hasSpecial(effects, SPECIAL_EFFECTS.STUDY_MOMENTUM)) {
const momentumBonus = Math.min(0.5, consecutiveStudyHours * 0.05);
studySpeedMult *= 1 + momentumBonus;
}
const progressGain = HOURS_PER_TICK * studySpeedMult;
// KNOWLEDGE_ECHO: 10% instant study chance
let instantProgress = 0;
if (hasSpecial(effects, SPECIAL_EFFECTS.KNOWLEDGE_ECHO) && Math.random() < 0.1) {
instantProgress = currentStudyTarget.required - currentStudyTarget.progress;
log = [`⚡ Knowledge Echo! Instant study progress!`, ...log.slice(0, 49)];
}
currentStudyTarget = {
...currentStudyTarget,
progress: currentStudyTarget.progress + progressGain + instantProgress,
};
// Increment consecutive study hours
consecutiveStudyHours += HOURS_PER_TICK;
// Check if study is complete
if (currentStudyTarget.progress >= currentStudyTarget.required) {
// STUDY_REFUND: 25% mana back on study complete
if (hasSpecial(effects, SPECIAL_EFFECTS.STUDY_REFUND) && lastStudyCost > 0) {
const refund = Math.floor(lastStudyCost * 0.25);
rawMana = Math.min(rawMana + refund, maxMana);
log = [`💰 Study Refund! Recovered ${refund} mana!`, ...log.slice(0, 49)];
}
// Reset study tracking
studyStartedAt = null;
consecutiveStudyHours = 0;
lastStudyCost = 0;
if (currentStudyTarget.type === 'skill') {
const skillId = currentStudyTarget.id;
const currentLevel = skills[skillId] || 0;
const newLevel = currentLevel + 1;
skills = { ...skills, [skillId]: newLevel };
skillProgress = { ...skillProgress, [skillId]: 0 };
log = [`${SKILLS_DEF[skillId]?.name} Lv.${newLevel} mastered!`, ...log.slice(0, 49)];
// Check if this skill unlocks effects (research skills)
const effectsToUnlock = EFFECT_RESEARCH_MAPPING[skillId];
if (effectsToUnlock && newLevel >= (SKILLS_DEF[skillId]?.max || 1)) {
const newEffects = effectsToUnlock.filter(e => !unlockedEffects.includes(e));
if (newEffects.length > 0) {
unlockedEffects = [...unlockedEffects, ...newEffects];
log = [`🔬 Unlocked ${newEffects.length} new enchantment effect(s)!`, ...log.slice(0, 49)];
}
}
// Special case: When enchanting skill reaches level 1, unlock mana bolt
if (skillId === 'enchanting' && newLevel >= 1) {
const enchantingEffects = ENCHANTING_UNLOCK_EFFECTS.filter(e => !unlockedEffects.includes(e));
if (enchantingEffects.length > 0) {
unlockedEffects = [...unlockedEffects, ...enchantingEffects];
log = [`✨ Enchantment design unlocked! Mana Bolt effect available.`, ...log.slice(0, 49)];
}
}
} else if (currentStudyTarget.type === 'spell') {
// Spells can no longer be studied directly - they come from equipment
// This branch is kept for backward compatibility but should not be used
const spellId = currentStudyTarget.id;
spells = { ...spells, [spellId]: { learned: true, level: 1, studyProgress: 0 } };
log = [`📖 ${SPELLS_DEF[spellId]?.name} learned!`, ...log.slice(0, 49)];
}
currentStudyTarget = null;
}
} }
} else { } else {
// Reset consecutive study hours when not studying // Reset consecutive study hours when not studying
@@ -540,12 +576,9 @@ export const useGameStore = create<GameStore>()(
} }
// Combat - MULTI-SPELL casting from all equipped weapons // Combat - MULTI-SPELL casting from all equipped weapons
let { currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, equipmentSpellStates, combo, totalTicks, lootInventory, achievements, totalDamageDealt, totalSpellsCast } = state; let { currentFloor, floorHP, floorMaxHP, maxFloorReached, signedPacts, equipmentSpellStates, combo, lootInventory, achievements, totalDamageDealt, totalSpellsCast } = state;
const floorElement = getFloorElement(currentFloor); const floorElement = getFloorElement(currentFloor);
// Increment total ticks
const newTotalTicks = totalTicks + 1;
// Combo decay - decay combo when not climbing or when decay timer expires // Combo decay - decay combo when not climbing or when decay timer expires
let newCombo = { ...combo }; let newCombo = { ...combo };
if (state.currentAction !== 'climb') { if (state.currentAction !== 'climb') {
@@ -878,6 +911,7 @@ export const useGameStore = create<GameStore>()(
totalSpellsCast, totalSpellsCast,
consecutiveStudyHours, consecutiveStudyHours,
studyStartedAt, studyStartedAt,
lastStudyCost,
...craftingUpdates, ...craftingUpdates,
}); });
}, },

View File

@@ -21,7 +21,7 @@ export function createStudySlice(
get: () => GameState get: () => GameState
): StudyActions { ): StudyActions {
return { return {
// Start studying a skill // Start studying a skill - mana is deducted per hour, not upfront
startStudyingSkill: (skillId: string) => { startStudyingSkill: (skillId: string) => {
const state = get(); const state = get();
const sk = SKILLS_DEF[skillId]; const sk = SKILLS_DEF[skillId];
@@ -37,22 +37,26 @@ export function createStudySlice(
} }
} }
// Check mana cost (with focused mind reduction) // Calculate total mana cost and cost per hour
const costMult = getStudyCostMultiplier(state.skills); const costMult = getStudyCostMultiplier(state.skills);
const cost = Math.floor(sk.base * (currentLevel + 1) * costMult); const totalCost = Math.floor(sk.base * (currentLevel + 1) * costMult);
if (state.rawMana < cost) return; const manaCostPerHour = Math.ceil(totalCost / sk.studyTime);
// Start studying // Must have at least 1 hour worth of mana to start
if (state.rawMana < manaCostPerHour) return;
// Start studying (no upfront cost - mana is deducted per hour during study)
set({ set({
rawMana: state.rawMana - cost,
currentAction: 'study', currentAction: 'study',
currentStudyTarget: { currentStudyTarget: {
type: 'skill', type: 'skill',
id: skillId, id: skillId,
progress: state.skillProgress[skillId] || 0, progress: state.skillProgress[skillId] || 0,
required: sk.studyTime, required: sk.studyTime,
manaCostPerHour: manaCostPerHour,
totalCost: totalCost,
}, },
log: [`📚 Started studying ${sk.name}...`, ...state.log.slice(0, 49)], log: [`📚 Started studying ${sk.name} (${manaCostPerHour} mana/hr)...`, ...state.log.slice(0, 49)],
}); });
}, },
@@ -62,28 +66,31 @@ export function createStudySlice(
const sp = SPELLS_DEF[spellId]; const sp = SPELLS_DEF[spellId];
if (!sp || state.spells[spellId]?.learned) return; if (!sp || state.spells[spellId]?.learned) return;
// Check mana cost (with focused mind reduction) // Calculate total mana cost and cost per hour
const costMult = getStudyCostMultiplier(state.skills); const costMult = getStudyCostMultiplier(state.skills);
const cost = Math.floor(sp.unlock * costMult); const totalCost = Math.floor(sp.unlock * costMult);
if (state.rawMana < cost) return; const studyTime = sp.studyTime || (sp.tier * 4);
const manaCostPerHour = Math.ceil(totalCost / studyTime);
const studyTime = sp.studyTime || (sp.tier * 4); // Default study time based on tier // Must have at least 1 hour worth of mana to start
if (state.rawMana < manaCostPerHour) return;
// Start studying // Start studying (no upfront cost - mana is deducted per hour during study)
set({ set({
rawMana: state.rawMana - cost,
currentAction: 'study', currentAction: 'study',
currentStudyTarget: { currentStudyTarget: {
type: 'spell', type: 'spell',
id: spellId, id: spellId,
progress: state.spells[spellId]?.studyProgress || 0, progress: state.spells[spellId]?.studyProgress || 0,
required: studyTime, required: studyTime,
manaCostPerHour: manaCostPerHour,
totalCost: totalCost,
}, },
spells: { spells: {
...state.spells, ...state.spells,
[spellId]: { ...(state.spells[spellId] || { learned: false, level: 0 }), studyProgress: state.spells[spellId]?.studyProgress || 0 }, [spellId]: { ...(state.spells[spellId] || { learned: false, level: 0 }), studyProgress: state.spells[spellId]?.studyProgress || 0 },
}, },
log: [`📚 Started studying ${sp.name}...`, ...state.log.slice(0, 49)], log: [`📚 Started studying ${sp.name} (${manaCostPerHour} mana/hr)...`, ...state.log.slice(0, 49)],
}); });
}, },
@@ -141,12 +148,19 @@ export function createStudySlice(
// Can't study same thing in parallel // Can't study same thing in parallel
if (state.currentStudyTarget.id === skillId) return; if (state.currentStudyTarget.id === skillId) return;
// Calculate mana cost for parallel study
const costMult = getStudyCostMultiplier(state.skills);
const totalCost = Math.floor(sk.base * (currentLevel + 1) * costMult);
const manaCostPerHour = Math.ceil(totalCost / sk.studyTime);
set({ set({
parallelStudyTarget: { parallelStudyTarget: {
type: 'skill', type: 'skill',
id: skillId, id: skillId,
progress: state.skillProgress[skillId] || 0, progress: state.skillProgress[skillId] || 0,
required: sk.studyTime, required: sk.studyTime,
manaCostPerHour: Math.ceil(manaCostPerHour / 2), // Half speed = half mana cost per tick
totalCost: totalCost,
}, },
log: [`📚 Started parallel study of ${sk.name}... (50% speed)`, ...state.log.slice(0, 49)], log: [`📚 Started parallel study of ${sk.name}... (50% speed)`, ...state.log.slice(0, 49)],
}); });

View File

@@ -330,6 +330,8 @@ export interface StudyTarget {
id: string; id: string;
progress: number; // Hours studied progress: number; // Hours studied
required: number; // Total hours needed required: number; // Total hours needed
manaCostPerHour: number; // Mana cost per hour of study
totalCost: number; // Total mana cost for the entire study
} }
export interface GameState { export interface GameState {