fix: SpireTab refresh - cast bar, mana costs, full-screen mode, exit button
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m14s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m14s
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ELEMENTS, SPELLS_DEF } from '@/lib/game/constants';
|
||||
import { calcDamage } from '@/lib/game/stores';
|
||||
import { getUnifiedEffects } from '@/lib/game/effects';
|
||||
import { formatSpellCost, getSpellCostColor } from '@/lib/game/formatting';
|
||||
import { canAffordSpellCost } from '@/lib/game/stores';
|
||||
import type { EquipmentSpellState } from '@/lib/game/types';
|
||||
|
||||
interface ActiveSpellsProps {
|
||||
activeEquipmentSpells: { spellId: string; equipmentId: string }[];
|
||||
spells: Record<string, { learned: boolean; level: number; studyProgress: number }>;
|
||||
equipmentSpellStates: EquipmentSpellState[];
|
||||
skills: Record<string, number>;
|
||||
skillUpgrades: Record<string, string[]>;
|
||||
skillTiers: Record<string, number>;
|
||||
signedPacts: number[];
|
||||
currentAction: string;
|
||||
floorElem: string;
|
||||
canCastSpell: (spellId: string) => boolean;
|
||||
}
|
||||
|
||||
export function SpireActiveSpells({
|
||||
activeEquipmentSpells,
|
||||
spells,
|
||||
equipmentSpellStates,
|
||||
skills,
|
||||
skillUpgrades,
|
||||
skillTiers,
|
||||
signedPacts,
|
||||
currentAction,
|
||||
floorElem,
|
||||
canCastSpell,
|
||||
}: ActiveSpellsProps) {
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="text-amber-400 text-xs font-semibold mb-3 uppercase tracking-wider flex items-center gap-2">
|
||||
<span>Active Spells ({activeEquipmentSpells.length})</span>
|
||||
</div>
|
||||
{activeEquipmentSpells.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{activeEquipmentSpells.map(({ spellId, equipmentId }) => {
|
||||
const spellDef = SPELLS_DEF[spellId];
|
||||
if (!spellDef) return null;
|
||||
const spellState = equipmentSpellStates?.find(
|
||||
s => s.spellId === spellId && s.sourceEquipment === equipmentId
|
||||
);
|
||||
const progress = spellState?.castProgress || 0;
|
||||
const canCast = canCastSpell(spellId);
|
||||
|
||||
return (
|
||||
<div key={`${spellId}-${equipmentId}`} className="p-2 rounded bg-gray-800/30 border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-semibold" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
|
||||
{spellDef.name}
|
||||
{spellDef.tier === 0 && <span className="ml-2 bg-gray-600 text-gray-200 text-xs px-1 rounded">Basic</span>}
|
||||
</span>
|
||||
<span className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'`}>{canCast ? '✓' : '✗'}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mb-1">
|
||||
⚔️ {calcDamage({ skills, signedPacts, skillUpgrades, skillTiers }, spellId, floorElem)} dmg • <span style={{ color: getSpellCostColor(spellDef.cost) }}>{formatSpellCost(spellDef.cost)}</span> {' '}• ⚡ {Math.floor(calcDamage({ skills, signedPacts, skillUpgrades, skillTiers }, spellId, floorElem) * (spellDef.castSpeed || 1))} dmg/hr
|
||||
</div>
|
||||
{currentAction === 'climb' && (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>Cast</span>
|
||||
<span>{(progress * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all duration-300" style={{ width: `${Math.min(100, progress * 100)}%`, background: spellDef.elem === 'raw' ? 'linear-gradient(90deg, #60A5FA99, #60A5FA)' : `linear-gradient(90deg, ${ELEMENTS[spellDef.elem]?.color}99, ${ELEMENTS[spellDef.elem]?.color})` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 text-sm">No spells on equipped weapons. Enchant a staff with spell effects in the Crafting tab.</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { GOLEMS_DEF, getGolemDamage, getGolemAttackSpeed } from '@/lib/game/data/golems';
|
||||
|
||||
interface SpireGolemsProps {
|
||||
golemancy: {
|
||||
summonedGolems: string[];
|
||||
enabledGolems: string[];
|
||||
};
|
||||
skills: Record<string, number>;
|
||||
floorElem: string;
|
||||
}
|
||||
|
||||
export function SpireGolems({ golemancy, skills, floorElem }: SpireGolemsProps) {
|
||||
if (golemancy.summonedGolems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-amber-600/50">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="text-amber-400 text-xs font-semibold mb-3 uppercase tracking-wider flex items-center gap-2">
|
||||
<Mountain className="w-4 h-4" />
|
||||
Active Golems ({golemancy.summonedGolems.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{golemancy.summonedGolems.map((summoned) => {
|
||||
const golemDef = GOLEMS_DEF[summoned.golemId];
|
||||
if (!golemDef) return null;
|
||||
const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888';
|
||||
const damage = getGolemDamage(summoned.golemId, skills);
|
||||
const attackSpeed = getGolemAttackSpeed(summoned.golemId, skills);
|
||||
|
||||
return (
|
||||
<div key={summoned.golemId} className="p-2 rounded bg-gray-800/30 border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mountain className="w-3 h-3" style={{ color: elemColor }} />
|
||||
<span className="text-xs font-semibold" style={{ color: elemColor }}>{golemDef.name}</span>
|
||||
</div>
|
||||
{golemDef.isAoe && <span className="text-xs border border-gray-600 px-1 rounded">AOE {golemDef.aoeTargets}</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">⚔️ {damage} DMG • ⚡ {attackSpeed.toFixed(1)}/hr</div>
|
||||
{currentAction === 'climb' && summoned.attackProgress > 0 && (
|
||||
<div className="mt-1">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-0.5">
|
||||
<span>Attack</span>
|
||||
<span>{(summoned.attackProgress * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-1 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300"
|
||||
style={{ width: `${Math.min(100, summoned.attackProgress * 100)}%`, background: elemColor }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Mountain } from 'lucide-react';
|
||||
import type { ActivityLogEntry } from '@/lib/game/types';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF } from '@/lib/game/constants';
|
||||
import { calcDamage } from '@/lib/game/stores';
|
||||
// Removed legacy import - getEnemyName not used in this component
|
||||
import { getActiveEquipmentSpells, getTotalDPS } from '@/lib/game/computed-stats';
|
||||
import { Mountain, ChevronDown } from 'lucide-react';
|
||||
import { ELEMENTS, GUARDIANS, SPELLS_DEF } from '@/lib/game/constants';
|
||||
import { calcDamage, getActiveEquipmentSpells, getTotalDPS } from '@/lib/game/utils';
|
||||
import { getUnifiedEffects } from '@/lib/game/effects';
|
||||
import { formatSpellCost, getSpellCostColor } from '@/lib/game/formatting';
|
||||
import { canAffordSpellCost, getFloorElement } from '@/lib/game/stores';
|
||||
import { canAffordSpellCost, getFloorElement } from '@/lib/game/utils';
|
||||
import { useManaStore, useSkillStore, useCombatStore, usePrestigeStore, useCraftingStore } from '@/lib/game/stores';
|
||||
import { GOLEMS_DEF, getGolemDamage, getGolemAttackSpeed } from '@/lib/game/data/golems';
|
||||
|
||||
@@ -22,22 +19,14 @@ import { RoomDisplay } from './RoomDisplay';
|
||||
import { FloorControls } from './FloorControls';
|
||||
import { CombatStatsPanel } from './CombatStatsPanel';
|
||||
import { ActivityLog } from './ActivityLog';
|
||||
import { SpireActiveSpells } from './SpireActiveSpells';
|
||||
import { SpireGolems } from './SpireGolems';
|
||||
import { DebugName } from '@/lib/game/debug-context';
|
||||
|
||||
// Room type configurations
|
||||
const ROOM_TYPE_CONFIG: Record<string, { label: string; icon: string; color: string }> = {
|
||||
combat: { label: 'Combat', icon: '⚔️', color: '#EF4444' },
|
||||
swarm: { label: 'Swarm', icon: '🐝', color: '#F59E0B' },
|
||||
speed: { label: 'Speed', icon: '💨', color: '#3B82F6' },
|
||||
guardian: { label: 'Guardian', icon: '🛡️', color: '#EF4444' },
|
||||
puzzle: { label: 'Puzzle', icon: '🧩', color: '#8B5CF6' },
|
||||
};
|
||||
|
||||
interface SpireTabProps {
|
||||
simpleMode?: boolean;
|
||||
}
|
||||
|
||||
// Check if player can enter spire mode
|
||||
const canEnterSpireMode = (spireMode: boolean): boolean => {
|
||||
return !spireMode;
|
||||
};
|
||||
@@ -51,9 +40,6 @@ export function SpireTab({ simpleMode = false }: SpireTabProps) {
|
||||
const currentAction = useCombatStore((s) => s.currentAction);
|
||||
const castProgress = useCombatStore((s) => s.castProgress);
|
||||
const activeSpell = useCombatStore((s) => s.activeSpell);
|
||||
const startClimbUp = useCombatStore((s) => s.startClimbUp);
|
||||
const startClimbDown = useCombatStore((s) => s.startClimbDown);
|
||||
const enterSpireMode = useCombatStore((s) => s.enterSpireMode);
|
||||
const spireMode = useCombatStore((s) => s.spireMode);
|
||||
const climbDirection = useCombatStore((s) => s.climbDirection) || 'up';
|
||||
const clearedFloors = useCombatStore((s) => s.clearedFloors || {});
|
||||
@@ -61,12 +47,14 @@ export function SpireTab({ simpleMode = false }: SpireTabProps) {
|
||||
const equipmentSpellStates = useCombatStore((s) => s.equipmentSpellStates);
|
||||
const golemancy = useCombatStore((s) => s.golemancy);
|
||||
const activityLog = useCombatStore((s) => s.activityLog);
|
||||
|
||||
const currentStudyTarget = useSkillStore((s) => s.currentStudyTarget);
|
||||
const parallelStudyTarget = useSkillStore((s) => s.parallelStudyTarget);
|
||||
const signedPacts = usePrestigeStore((s) => s.signedPacts);
|
||||
const skills = useSkillStore((s) => s.skills);
|
||||
const skillUpgrades = useSkillStore((s) => s.skillUpgrades);
|
||||
const skillTiers = useSkillStore((s) => s.skillTiers);
|
||||
|
||||
const rawMana = useManaStore((s) => s.rawMana);
|
||||
const elements = useManaStore((s) => s.elements);
|
||||
const equippedInstances = useCraftingStore((s) => s.equippedInstances);
|
||||
@@ -84,7 +72,13 @@ export function SpireTab({ simpleMode = false }: SpireTabProps) {
|
||||
const currentGuardian = GUARDIANS[currentFloor];
|
||||
const isFloorCleared = clearedFloors[currentFloor];
|
||||
const roomType = currentRoom?.roomType || 'combat';
|
||||
const roomConfig = ROOM_TYPE_CONFIG[roomType] || ROOM_TYPE_CONFIG.combat;
|
||||
const roomConfig = {
|
||||
combat: { label: 'Combat', icon: '⚔️', color: '#EF4444' },
|
||||
swarm: { label: 'Swarm', icon: '🐝', color: '#F59E0B' },
|
||||
speed: { label: 'Speed', icon: '💨', color: '#3B82F6' },
|
||||
guardian: { label: 'Guardian', icon: '🛡️', color: '#EF4444' },
|
||||
puzzle: { label: 'Puzzle', icon: '🧩', color: '#8B5CF6' },
|
||||
}[roomType] || { label: 'Combat', icon: '⚔️', color: '#EF4444' };
|
||||
|
||||
const activeEquipmentSpells = useMemo(
|
||||
() => getActiveEquipmentSpells(equippedInstances, equipmentInstances),
|
||||
@@ -106,292 +100,247 @@ export function SpireTab({ simpleMode = false }: SpireTabProps) {
|
||||
[skills, signedPacts, skillUpgrades, skillTiers, upgradeEffects, floorElem]
|
||||
);
|
||||
|
||||
// Enemy display info
|
||||
const primaryEnemy = currentRoom?.enemies?.[0] || null;
|
||||
const swarmEnemies = roomType === 'swarm' && currentRoom?.enemies ? currentRoom.enemies : [];
|
||||
|
||||
// Spell casting check
|
||||
const canCastSpell = (spellId: string): boolean => {
|
||||
const spell = SPELLS_DEF[spellId];
|
||||
if (!spell) return false;
|
||||
if (!spell || !spell.cost) return false;
|
||||
return canAffordSpellCost(spell.cost, rawMana, elements);
|
||||
};
|
||||
|
||||
// Climb handler
|
||||
const handleClimb = (direction: 'up' | 'down') => {
|
||||
if (direction === 'up') {
|
||||
startClimbUp();
|
||||
} else {
|
||||
startClimbDown();
|
||||
}
|
||||
const getSkillName = (skillId: string): string => {
|
||||
return SPELLS_DEF[skillId]?.name || skillId;
|
||||
};
|
||||
|
||||
const getSkillName = (skillId: string): string => {
|
||||
return SKILLS_DEF[skillId]?.name || skillId;
|
||||
};
|
||||
// Handle exit spire mode
|
||||
const exitSpireMode = useCombatStore((s) => s.exitSpireMode);
|
||||
|
||||
return (
|
||||
<DebugName name="SpireTab">
|
||||
<div className="grid gap-4">
|
||||
{/* Enter Spire Mode - Normal mode only */}
|
||||
{!simpleMode && (
|
||||
<Card className="bg-gray-900/80 border-amber-600/50">
|
||||
<CardContent className="pt-4">
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-700 hover:to-orange-700"
|
||||
size="lg"
|
||||
onClick={enterSpireMode}
|
||||
disabled={!canEnterSpireMode(spireMode)}
|
||||
>
|
||||
<Mountain className="w-5 h-5 mr-2" />
|
||||
Enter Spire Mode
|
||||
</Button>
|
||||
<div className="text-xs text-gray-400 text-center mt-2">
|
||||
Climb the Spire to face guardians and earn pacts
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Spire Header */}
|
||||
<SpireHeader
|
||||
currentFloor={currentFloor}
|
||||
maxFloorReached={maxFloorReached}
|
||||
signedPacts={signedPacts.length}
|
||||
isGuardianFloor={isGuardianFloor}
|
||||
roomType={roomType}
|
||||
roomLabel={roomConfig.label}
|
||||
roomIcon={roomConfig.icon}
|
||||
roomColor={roomConfig.color}
|
||||
floorElem={floorElem}
|
||||
floorElemDef={floorElemDef}
|
||||
simpleMode={simpleMode}
|
||||
/>
|
||||
|
||||
{/* Active Spells Card - Spire Mode only */}
|
||||
{simpleMode && (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="text-amber-400 text-xs font-semibold mb-3 uppercase tracking-wider">
|
||||
Active Spells ({activeEquipmentSpells.length})
|
||||
</div>
|
||||
{activeEquipmentSpells.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{activeEquipmentSpells.map(({ spellId, equipmentId }) => {
|
||||
const spellDef = SPELLS_DEF[spellId];
|
||||
if (!spellDef) return null;
|
||||
const spellState = equipmentSpellStates?.find(
|
||||
s => s.spellId === spellId && s.sourceEquipment === equipmentId
|
||||
);
|
||||
const progress = spellState?.castProgress || 0;
|
||||
const canCast = canCastSpell(spellId);
|
||||
|
||||
return (
|
||||
<div key={`${spellId}-${equipmentId}`} className="p-2 rounded bg-gray-800/30 border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-semibold" style={{ color: spellDef.elem === 'raw' ? '#60A5FA' : ELEMENTS[spellDef.elem]?.color }}>
|
||||
{spellDef.name}
|
||||
{spellDef.tier === 0 && <span className="ml-2 bg-gray-600 text-gray-200 text-xs px-1 rounded">Basic</span>}
|
||||
</span>
|
||||
<span className={`text-xs ${canCast ? 'text-green-400' : 'text-red-400'}`}>{canCast ? '✓' : '✗'}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mb-1">
|
||||
⚔️ {calcDamage({ skills, signedPacts, skillUpgrades, skillTiers }, spellId, floorElem)} dmg • <span style={{ color: getSpellCostColor(spellDef.cost) }}>{formatSpellCost(spellDef.cost)}</span> {' '}• ⚡ {Math.floor(calcDamage({ skills, signedPacts, skillUpgrades, skillTiers }, spellId, floorElem) * (spellDef.castSpeed || 1))} dmg/hr
|
||||
</div>
|
||||
{currentAction === 'climb' && (
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex justify-between text-xs text-gray-500">
|
||||
<span>Cast</span>
|
||||
<span>{(progress * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all duration-300" style={{ width: `${Math.min(100, progress * 100)}%`, background: spellDef.elem === 'raw' ? 'linear-gradient(90deg, #60A5FA99, #60A5FA)' : `linear-gradient(90deg, ${ELEMENTS[spellDef.elem]?.color}99, ${ELEMENTS[spellDef.elem]?.color})` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="grid gap-4">
|
||||
{/* Enter Spire Mode - Normal mode only */}
|
||||
{!simpleMode && (
|
||||
<Card className="bg-gray-900/80 border-amber-600/50">
|
||||
<CardContent className="pt-4">
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-amber-600 to-orange-600 hover:from-amber-700 hover:to-orange-700"
|
||||
size="lg"
|
||||
onClick={useCombatStore.getState().enterSpireMode}
|
||||
disabled={!canEnterSpireMode(spireMode)}
|
||||
>
|
||||
<Mountain className="w-5 h-5 mr-2" />
|
||||
Enter Spire Mode
|
||||
</Button>
|
||||
<div className="text-xs text-gray-400 text-center mt-2">
|
||||
Climb the Spire to face guardians and earn pacts
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 text-sm">No spells on equipped weapons. Enchant a staff with spell effects in the Crafting tab.</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Summoned Golems */}
|
||||
{simpleMode && golemancy.summonedGolems.length > 0 && (
|
||||
<Card className="bg-gray-900/80 border-amber-600/50">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="text-amber-400 text-xs font-semibold mb-3 uppercase tracking-wider flex items-center gap-2">
|
||||
<Mountain className="w-4 h-4" />
|
||||
Active Golems ({golemancy.summonedGolems.length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{golemancy.summonedGolems.map((summoned) => {
|
||||
const golemDef = GOLEMS_DEF[summoned.golemId];
|
||||
if (!golemDef) return null;
|
||||
const elemColor = ELEMENTS[golemDef.baseManaType]?.color || '#888';
|
||||
const damage = getGolemDamage(summoned.golemId, skills);
|
||||
const attackSpeed = getGolemAttackSpeed(summoned.golemId, skills);
|
||||
{/* Exit Spire Mode - Spire mode only */}
|
||||
{simpleMode && (
|
||||
<Card className="bg-gray-900/80 border-red-600/50">
|
||||
<CardContent className="pt-4">
|
||||
<Button
|
||||
className="w-full bg-gradient-to-r from-red-600 to-red-700 hover:from-red-700 hover:to-red-800"
|
||||
size="lg"
|
||||
onClick={exitSpireMode}
|
||||
>
|
||||
<ChevronDown className="w-5 h-5 mr-2" />
|
||||
Exit Spire Mode
|
||||
</Button>
|
||||
<div className="text-xs text-gray-400 text-center mt-2">
|
||||
Climb down to floor 1 to return to the main game
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
return (
|
||||
<div key={summoned.golemId} className="p-2 rounded bg-gray-800/30 border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mountain className="w-3 h-3" style={{ color: elemColor }} />
|
||||
<span className="text-xs font-semibold" style={{ color: elemColor }}>{golemDef.name}</span>
|
||||
</div>
|
||||
{golemDef.isAoe && <span className="text-xs border border-gray-600 px-1 rounded">AOE {golemDef.aoeTargets}</span>}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">⚔️ {damage} DMG • ⚡ {attackSpeed.toFixed(1)}/hr</div>
|
||||
{currentAction === 'climb' && summoned.attackProgress > 0 && (
|
||||
<div className="mt-1">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-0.5">
|
||||
<span>Attack</span>
|
||||
<span>{(summoned.attackProgress * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-1 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all duration-300" style={{ width: `${Math.min(100, summoned.attackProgress * 100)}%`, background: elemColor }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Guardian Panel */}
|
||||
{isGuardianFloor && simpleMode && (
|
||||
<GuardianPanel currentFloor={currentFloor} floorElemDef={floorElemDef} />
|
||||
)}
|
||||
|
||||
{/* Room Display */}
|
||||
{simpleMode && (
|
||||
<RoomDisplay
|
||||
roomType={roomType}
|
||||
roomConfig={roomConfig}
|
||||
primaryEnemy={primaryEnemy}
|
||||
swarmEnemies={swarmEnemies}
|
||||
puzzleId={currentRoom?.puzzleId}
|
||||
puzzleProgress={currentRoom?.puzzleProgress}
|
||||
simpleMode={true}
|
||||
floorElemDef={floorElemDef}
|
||||
floorHP={floorHP}
|
||||
floorMaxHP={floorMaxHP}
|
||||
totalDPS={totalDPS}
|
||||
currentAction={currentAction}
|
||||
activeEquipmentSpells={activeEquipmentSpells}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floor Controls */}
|
||||
{simpleMode && (
|
||||
<FloorControls
|
||||
{/* Spire Header */}
|
||||
<SpireHeader
|
||||
currentFloor={currentFloor}
|
||||
floorHP={floorHP}
|
||||
floorMaxHP={floorMaxHP}
|
||||
maxFloorReached={maxFloorReached}
|
||||
equipmentSpellStates={equipmentSpellStates}
|
||||
skills={skills}
|
||||
signedPacts={signedPacts}
|
||||
storeCurrentAction={currentAction}
|
||||
climbDirection={climbDirection}
|
||||
signedPacts={signedPacts.length}
|
||||
isGuardianFloor={isGuardianFloor}
|
||||
currentRoom={currentRoom}
|
||||
currentGuardian={currentGuardian}
|
||||
isFloorCleared={isFloorCleared}
|
||||
floorElemDef={floorElemDef}
|
||||
roomType={roomType}
|
||||
roomConfig={roomConfig}
|
||||
activeEquipmentSpells={activeEquipmentSpells}
|
||||
roomLabel={roomConfig.label}
|
||||
roomIcon={roomConfig.icon}
|
||||
roomColor={roomConfig.color}
|
||||
floorElem={floorElem}
|
||||
totalDPS={totalDPS}
|
||||
calcDamage={calcDamage}
|
||||
SPELLS_DEF={SPELLS_DEF}
|
||||
canCastSpell={canCastSpell}
|
||||
handleClimb={handleClimb}
|
||||
formatSpellCost={formatSpellCost}
|
||||
getSpellCostColor={getSpellCostColor}
|
||||
floorElemDef={floorElemDef}
|
||||
simpleMode={simpleMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Combat Stats Panel */}
|
||||
{simpleMode && (
|
||||
<CombatStatsPanel
|
||||
activeEquipmentSpells={activeEquipmentSpells}
|
||||
storeCurrentAction={currentAction}
|
||||
totalDPS={totalDPS}
|
||||
calcDamage={calcDamage}
|
||||
formatSpellCost={formatSpellCost}
|
||||
getSpellCostColor={getSpellCostColor}
|
||||
SPELLS_DEF={SPELLS_DEF}
|
||||
upgradeEffects={upgradeEffects}
|
||||
canCastSpell={canCastSpell}
|
||||
studySpeedMult={1}
|
||||
/>
|
||||
)}
|
||||
{/* Active Spells Card - Spire Mode only */}
|
||||
{simpleMode && (
|
||||
<SpireActiveSpells
|
||||
activeEquipmentSpells={activeEquipmentSpells}
|
||||
spells={useSkillStore.getState().spells}
|
||||
equipmentSpellStates={equipmentSpellStates}
|
||||
skills={skills}
|
||||
skillUpgrades={skillUpgrades}
|
||||
skillTiers={skillTiers}
|
||||
signedPacts={signedPacts}
|
||||
currentAction={currentAction}
|
||||
floorElem={floorElem}
|
||||
canCastSpell={canCastSpell}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Activity Log - Spire Mode only */}
|
||||
{simpleMode && <ActivityLog activityLog={activityLog} />}
|
||||
{/* Summoned Golems */}
|
||||
{simpleMode && golemancy.summonedGolems.length > 0 && (
|
||||
<SpireGolems
|
||||
golemancy={golemancy}
|
||||
skills={skills}
|
||||
currentAction={currentAction}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Study Progress - Normal mode only */}
|
||||
{!simpleMode && currentStudyTarget && (
|
||||
<Card className="bg-gray-900/80 border-purple-600/50">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="text-xs text-gray-400 mb-2">Study: {getSkillName(currentStudyTarget.id)}</div>
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all duration-300 bg-purple-500" style={{ width: `${Math.min(100, (currentStudyTarget.progress / currentStudyTarget.required) * 100)}%` }} />
|
||||
</div>
|
||||
{parallelStudyTarget && (
|
||||
<div className="mt-3 p-2 rounded border border-cyan-600/50 bg-cyan-900/20">
|
||||
<div className="text-xs text-cyan-300 mb-1">Parallel: {getSkillName(parallelStudyTarget.id)} (50% speed)</div>
|
||||
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all duration-300 bg-cyan-500" style={{ width: `${Math.min(100, (parallelStudyTarget.progress / parallelStudyTarget.required) * 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{/* Guardian Panel */}
|
||||
{isGuardianFloor && simpleMode && (
|
||||
<GuardianPanel
|
||||
currentFloor={currentFloor}
|
||||
floorElemDef={floorElemDef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Crafting Progress - Normal mode only */}
|
||||
{!simpleMode && (designProgress || preparationProgress || applicationProgress) && (
|
||||
<Card className="bg-gray-900/80 border-cyan-600/50">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
{designProgress && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs text-gray-400 mb-1">Design Progress</div>
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all duration-300 bg-cyan-500" style={{ width: `${Math.min(100, (designProgress.progress / designProgress.required) * 100)}%` }} />
|
||||
</div>
|
||||
{/* Room Display */}
|
||||
{simpleMode && (
|
||||
<RoomDisplay
|
||||
roomType={roomType}
|
||||
roomConfig={roomConfig}
|
||||
primaryEnemy={currentRoom?.enemies?.[0] || null}
|
||||
swarmEnemies={roomType === 'swarm' ? currentRoom?.enemies || [] : []}
|
||||
puzzleId={currentRoom?.puzzleId}
|
||||
puzzleProgress={currentRoom?.puzzleProgress}
|
||||
simpleMode={true}
|
||||
floorElemDef={floorElemDef}
|
||||
floorHP={floorHP}
|
||||
floorMaxHP={floorMaxHP}
|
||||
totalDPS={totalDPS}
|
||||
currentAction={currentAction}
|
||||
activeEquipmentSpells={activeEquipmentSpells}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floor Controls */}
|
||||
{simpleMode && (
|
||||
<FloorControls
|
||||
currentFloor={currentFloor}
|
||||
floorHP={floorHP}
|
||||
floorMaxHP={floorMaxHP}
|
||||
maxFloorReached={maxFloorReached}
|
||||
equipmentSpellStates={equipmentSpellStates}
|
||||
skills={skills}
|
||||
signedPacts={signedPacts}
|
||||
storeCurrentAction={currentAction}
|
||||
climbDirection={climbDirection}
|
||||
isGuardianFloor={isGuardianFloor}
|
||||
currentRoom={currentRoom}
|
||||
currentGuardian={currentGuardian}
|
||||
isFloorCleared={isFloorCleared}
|
||||
floorElemDef={floorElemDef}
|
||||
roomType={roomType}
|
||||
roomConfig={roomConfig}
|
||||
activeEquipmentSpells={activeEquipmentSpells}
|
||||
floorElem={floorElem}
|
||||
totalDPS={totalDPS}
|
||||
calcDamage={calcDamage}
|
||||
SPELLS_DEF={SPELLS_DEF}
|
||||
canCastSpell={canCastSpell}
|
||||
handleClimb={(dir) => dir === 'up' ? useCombatStore.getState().startClimbUp() : useCombatStore.getState().startClimbDown()}
|
||||
formatSpellCost={formatSpellCost}
|
||||
getSpellCostColor={getSpellCostColor}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Combat Stats Panel */}
|
||||
{simpleMode && (
|
||||
<CombatStatsPanel
|
||||
activeEquipmentSpells={activeEquipmentSpells}
|
||||
storeCurrentAction={currentAction}
|
||||
totalDPS={totalDPS}
|
||||
calcDamage={calcDamage}
|
||||
formatSpellCost={formatSpellCost}
|
||||
getSpellCostColor={getSpellCostColor}
|
||||
SPELLS_DEF={SPELLS_DEF}
|
||||
upgradeEffects={upgradeEffects}
|
||||
canCastSpell={canCastSpell}
|
||||
studySpeedMult={1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Activity Log - Spire Mode only */}
|
||||
{simpleMode && <ActivityLog activityLog={activityLog} />}
|
||||
|
||||
{/* Study Progress - Normal mode only */}
|
||||
{!simpleMode && currentStudyTarget && (
|
||||
<Card className="bg-gray-900/80 border-purple-600/50">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
<div className="text-xs text-gray-400 mb-2">Study: {getSkillName(currentStudyTarget.id)}</div>
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300 bg-purple-500"
|
||||
style={{ width: `${Math.min(100, (currentStudyTarget.progress / currentStudyTarget.required) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{preparationProgress && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs text-gray-400 mb-1">Preparation Progress</div>
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all duration-300 bg-cyan-500" style={{ width: `${Math.min(100, (preparationProgress.progress / preparationProgress.required) * 100)}%` }} />
|
||||
{parallelStudyTarget && (
|
||||
<div className="mt-3 p-2 rounded border border-cyan-600/50 bg-cyan-900/20">
|
||||
<div className="text-xs text-cyan-300 mb-1">Parallel: {getSkillName(parallelStudyTarget.id)} (50% speed)</div>
|
||||
<div className="h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300 bg-cyan-500"
|
||||
style={{ width: `${Math.min(100, (parallelStudyTarget.progress / parallelStudyTarget.required) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{applicationProgress && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 mb-1">Application Progress</div>
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full transition-all duration-300 bg-cyan-500" style={{ width: `${Math.min(100, (applicationProgress.progress / applicationProgress.required) * 100)}%` }} />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Crafting Progress - Normal mode only */}
|
||||
{!simpleMode && (designProgress || preparationProgress || applicationProgress) && (
|
||||
<Card className="bg-gray-900/80 border-cyan-600/50">
|
||||
<CardContent className="pt-4 pb-4">
|
||||
{designProgress && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs text-gray-400 mb-1">Design Progress</div>
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300 bg-cyan-500"
|
||||
style={{ width: `${Math.min(100, (designProgress.progress / designProgress.required) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</DebugName>
|
||||
)}
|
||||
{preparationProgress && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs text-gray-400 mb-1">Preparation Progress</div>
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300 bg-cyan-500"
|
||||
style={{ width: `${Math.min(100, (preparationProgress.progress / preparationProgress.required) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{applicationProgress && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 mb-1">Application Progress</div>
|
||||
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-300 bg-cyan-500"
|
||||
style={{ width: `${Math.min(100, (applicationProgress.progress / applicationProgress.required) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</DebugName>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useCombatStore } from '../combatStore';
|
||||
import { useManaStore } from '../manaStore';
|
||||
import { useCraftingStore } from '../craftingStore';
|
||||
|
||||
describe('SpireTab Refresh & Casting Fixes', () => {
|
||||
beforeEach(() => {
|
||||
// Reset stores
|
||||
useCombatStore.setState({
|
||||
currentFloor: 1,
|
||||
floorHP: 100,
|
||||
floorMaxHP: 100,
|
||||
maxFloorReached: 1,
|
||||
activeSpell: 'manaBolt',
|
||||
currentAction: 'climb',
|
||||
castProgress: 0,
|
||||
spireMode: false,
|
||||
equipmentSpellStates: [
|
||||
{ spellId: 'manaBolt', sourceEquipment: 'test-staff', castProgress: 0 }
|
||||
],
|
||||
});
|
||||
|
||||
useManaStore.setState({
|
||||
rawMana: 100,
|
||||
elements: {
|
||||
fire: { current: 50, max: 100, unlocked: true },
|
||||
transference: { current: 20, max: 100, unlocked: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should update equipment spell cast progress', () => {
|
||||
const initialState = useCombatStore.getState();
|
||||
expect(initialState.castProgress).toBe(0);
|
||||
|
||||
// Simulate combat tick (simplified)
|
||||
const newProgress = 0.5;
|
||||
useCombatStore.setState({ castProgress: newProgress });
|
||||
|
||||
const updatedState = useCombatStore.getState();
|
||||
expect(updatedState.castProgress).toBe(newProgress);
|
||||
});
|
||||
|
||||
it('should deduct mana when casting spells', () => {
|
||||
const initialMana = useManaStore.getState().rawMana;
|
||||
expect(initialMana).toBeGreaterThan(0);
|
||||
|
||||
// Simulate spell cast (simplified - mana should decrease)
|
||||
const spellCost = 10;
|
||||
useManaStore.setState({ rawMana: initialMana - spellCost });
|
||||
|
||||
const updatedMana = useManaStore.getState().rawMana;
|
||||
expect(updatedMana).toBe(initialMana - spellCost);
|
||||
});
|
||||
|
||||
it('should have exitSpireMode function', () => {
|
||||
const state = useCombatStore.getState();
|
||||
expect(typeof state.exitSpireMode).toBe('function');
|
||||
});
|
||||
|
||||
it('should set spireMode to false when exitSpireMode is called', () => {
|
||||
// Enter spire mode first
|
||||
useCombatStore.setState({ spireMode: true });
|
||||
expect(useCombatStore.getState().spireMode).toBe(true);
|
||||
|
||||
// Exit spire mode
|
||||
useCombatStore.getState().exitSpireMode();
|
||||
expect(useCombatStore.getState().spireMode).toBe(false);
|
||||
});
|
||||
|
||||
it('should NOT show study components when spireMode is true', () => {
|
||||
// This is a UI test - we can only verify the state
|
||||
useCombatStore.setState({ spireMode: true });
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.spireMode).toBe(true);
|
||||
|
||||
// Study target should be null or ignored when in spire mode
|
||||
// (UI conditionally renders based on spireMode)
|
||||
});
|
||||
|
||||
it('should process equipment spell states in combat tick', () => {
|
||||
const state = useCombatStore.getState();
|
||||
expect(state.equipmentSpellStates.length).toBeGreaterThan(0);
|
||||
|
||||
// Simulate equipment spell progress
|
||||
const updatedStates = state.equipmentSpellStates.map(s =>
|
||||
({ ...s, castProgress: 0.5 })
|
||||
);
|
||||
useCombatStore.setState({ equipmentSpellStates: updatedStates });
|
||||
|
||||
const updatedState = useCombatStore.getState();
|
||||
expect(updatedState.equipmentSpellStates[0].castProgress).toBe(0.5);
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ export function processCombatTick(
|
||||
let currentFloor = state.currentFloor;
|
||||
let floorMaxHP = state.floorMaxHP;
|
||||
|
||||
// Process complete casts
|
||||
// Process complete casts for active spell
|
||||
while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements)) {
|
||||
// Deduct spell cost
|
||||
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
|
||||
@@ -91,12 +91,56 @@ export function processCombatTick(
|
||||
}
|
||||
}
|
||||
|
||||
// Process equipment spell states (for progress bars in UI)
|
||||
const updatedEquipmentSpellStates = [...state.equipmentSpellStates];
|
||||
for (let i = 0; i < updatedEquipmentSpellStates.length; i++) {
|
||||
const eSpell = updatedEquipmentSpellStates[i];
|
||||
const eSpellDef = SPELLS_DEF[eSpell.spellId];
|
||||
if (!eSpellDef) continue;
|
||||
|
||||
// Calculate progress for this equipment spell
|
||||
const eSpellCastSpeed = eSpellDef.castSpeed || 1;
|
||||
const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * totalAttackSpeed;
|
||||
let eCastProgress = (eSpell.castProgress || 0) + eProgressPerTick;
|
||||
|
||||
// Process complete casts for equipment spells
|
||||
while (eCastProgress >= 1 && canAffordSpellCost(eSpellDef.cost, rawMana, elements)) {
|
||||
// Deduct cost
|
||||
const eAfterCost = deductSpellCost(eSpellDef.cost, rawMana, elements);
|
||||
rawMana = eAfterCost.rawMana;
|
||||
elements = eAfterCost.elements;
|
||||
totalManaGathered += eSpellDef.cost.amount;
|
||||
|
||||
// Calculate damage
|
||||
const eFloorElement = getFloorElement(currentFloor);
|
||||
const eDamage = calcDamage(
|
||||
{ skills, signedPacts: usePrestigeStore.getState().signedPacts },
|
||||
eSpell.spellId,
|
||||
eFloorElement,
|
||||
);
|
||||
|
||||
const eResult = onDamageDealt(eDamage);
|
||||
rawMana = eResult.rawMana;
|
||||
elements = eResult.elements;
|
||||
const eFinalDamage = eResult.modifiedDamage || eDamage;
|
||||
|
||||
floorHP = Math.max(0, floorHP - eFinalDamage);
|
||||
eCastProgress -= 1;
|
||||
|
||||
if (floorHP <= 0) break; // Floor cleared, stop processing
|
||||
}
|
||||
|
||||
// Update equipment spell state
|
||||
updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 };
|
||||
}
|
||||
|
||||
set({
|
||||
currentFloor,
|
||||
floorHP,
|
||||
floorMaxHP: getFloorMaxHP(currentFloor),
|
||||
maxFloorReached: Math.max(state.maxFloorReached, currentFloor),
|
||||
castProgress,
|
||||
equipmentSpellStates: updatedEquipmentSpellStates,
|
||||
});
|
||||
|
||||
return { rawMana, elements, logMessages, totalManaGathered };
|
||||
|
||||
Reference in New Issue
Block a user