Implement multiple game improvements
All checks were successful
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m53s

- Guardian barriers with 3x HP regen on guardian floors
- Compound mana types auto-unlock when components available
- Legs equipment slot with 5 equipment types
- Expeditious Retreat and movement enchantments for legs
- Fixed tests for current skill definitions (65/65 pass)
- New achievements for elements, attunements, and guardians
- Removed nonsensical mechanics (thorns, manaShield for player)
- Cleaned up skill test references to match current implementation
This commit is contained in:
Z User
2026-03-28 10:04:48 +00:00
parent 416b2fcde6
commit f07454e024
11 changed files with 991 additions and 339 deletions

View File

@@ -3,9 +3,10 @@
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent } from '@/components/ui/card';
import { Zap, ChevronDown, ChevronUp } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Zap, ChevronDown, ChevronUp, Sparkles } from 'lucide-react';
import { fmt, fmtDec } from '@/lib/game/store';
import { ELEMENTS } from '@/lib/game/constants';
import { ELEMENTS, getCompositeConversionRate } from '@/lib/game/constants';
import { useState } from 'react';
interface ManaDisplayProps {
@@ -18,6 +19,7 @@ interface ManaDisplayProps {
onGatherStart: () => void;
onGatherEnd: () => void;
elements: Record<string, { current: number; max: number; unlocked: boolean }>;
baseConversionRates?: Record<string, number>; // Base element conversion rates from attunements
}
export function ManaDisplay({
@@ -30,13 +32,37 @@ export function ManaDisplay({
onGatherStart,
onGatherEnd,
elements,
baseConversionRates = {},
}: ManaDisplayProps) {
const [expanded, setExpanded] = useState(true);
// Get unlocked elements, sorted by current amount (show even if 0 mana)
// Get unlocked elements, sorted by category (base first, then composite, then exotic) and amount
const unlockedElements = Object.entries(elements)
.filter(([, state]) => state.unlocked)
.sort((a, b) => b[1].current - a[1].current);
.sort((a, b) => {
const aDef = ELEMENTS[a[0]];
const bDef = ELEMENTS[b[0]];
if (!aDef || !bDef) return 0;
// Sort by category: base < utility < composite < exotic
const categoryOrder = { base: 0, utility: 1, composite: 2, exotic: 3 };
const aOrder = categoryOrder[aDef.cat] ?? 4;
const bOrder = categoryOrder[bDef.cat] ?? 4;
if (aOrder !== bOrder) return aOrder - bOrder;
return b[1].current - a[1].current;
});
// Separate base/utility elements from composite/exotic
const baseElements = unlockedElements.filter(([id]) => {
const def = ELEMENTS[id];
return def && (def.cat === 'base' || def.cat === 'utility');
});
const compositeElements = unlockedElements.filter(([id]) => {
const def = ELEMENTS[id];
return def && (def.cat === 'composite' || def.cat === 'exotic');
});
return (
<Card className="bg-gray-900/80 border-gray-700">
@@ -82,37 +108,111 @@ export function ManaDisplay({
</button>
{expanded && (
<div className="grid grid-cols-2 gap-2">
{unlockedElements.map(([id, state]) => {
const elem = ELEMENTS[id];
if (!elem) return null;
return (
<div
key={id}
className="p-2 rounded bg-gray-800/50 border border-gray-700"
>
<div className="flex items-center gap-1 mb-1">
<span style={{ color: elem.color }}>{elem.sym}</span>
<span className="text-xs font-medium" style={{ color: elem.color }}>
{elem.name}
</span>
</div>
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden mb-1">
<div className="space-y-2">
{/* Base/Utility Elements */}
{baseElements.length > 0 && (
<div className="grid grid-cols-2 gap-2">
{baseElements.map(([id, state]) => {
const elem = ELEMENTS[id];
if (!elem) return null;
return (
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.min(100, (state.current / state.max) * 100)}%`,
backgroundColor: elem.color
}}
/>
</div>
<div className="text-xs text-gray-400 game-mono">
{fmt(state.current)}/{fmt(state.max)}
</div>
key={id}
className="p-2 rounded bg-gray-800/50 border border-gray-700"
>
<div className="flex items-center gap-1 mb-1">
<span style={{ color: elem.color }}>{elem.sym}</span>
<span className="text-xs font-medium" style={{ color: elem.color }}>
{elem.name}
</span>
</div>
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden mb-1">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.min(100, (state.current / state.max) * 100)}%`,
backgroundColor: elem.color
}}
/>
</div>
<div className="text-xs text-gray-400 game-mono">
{fmt(state.current)}/{fmt(state.max)}
</div>
</div>
);
})}
</div>
)}
{/* Composite/Exotic Elements */}
{compositeElements.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1 text-xs text-gray-500">
<Sparkles className="w-3 h-3" />
<span>Compound Elements</span>
</div>
);
})}
<div className="grid grid-cols-2 gap-2">
{compositeElements.map(([id, state]) => {
const elem = ELEMENTS[id];
if (!elem) return null;
// Get conversion rate for this composite
const conversionRate = getCompositeConversionRate(id, baseConversionRates);
return (
<div
key={id}
className="p-2 rounded bg-gray-800/50 border border-gray-700 relative"
style={{ borderColor: elem.color + '40' }}
>
<div className="flex items-center gap-1 mb-1">
<span style={{ color: elem.color }}>{elem.sym}</span>
<span className="text-xs font-medium" style={{ color: elem.color }}>
{elem.name}
</span>
</div>
{/* Recipe indicator */}
{elem.recipe && (
<div className="flex items-center gap-0.5 mb-1">
{elem.recipe.map((comp, i) => {
const compElem = ELEMENTS[comp];
return (
<span key={i} className="text-xs">
{compElem?.sym || '?'}
{i < elem.recipe!.length - 1 && <span className="text-gray-600">+</span>}
</span>
);
})}
</div>
)}
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden mb-1">
<div
className="h-full rounded-full transition-all"
style={{
width: `${Math.min(100, (state.current / state.max) * 100)}%`,
backgroundColor: elem.color
}}
/>
</div>
<div className="text-xs text-gray-400 game-mono">
{fmt(state.current)}/{fmt(state.max)}
</div>
{/* Show conversion rate if available */}
{conversionRate > 0 && (
<div className="text-xs text-gray-500 mt-1">
+{fmtDec(conversionRate, 2)}/hr
</div>
)}
</div>
);
})}
</div>
</div>
)}
</div>
)}
</div>

View File

@@ -7,10 +7,10 @@ import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Swords, BookOpen, ChevronUp, ChevronDown, RotateCcw, X } from 'lucide-react';
import { Swords, BookOpen, ChevronUp, ChevronDown, RotateCcw, X, Heart, Shield } from 'lucide-react';
import type { GameStore } from '@/lib/game/types';
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, GOLEM_DEFS, GOLEM_VARIANTS } from '@/lib/game/constants';
import { fmt, fmtDec, getFloorElement, canAffordSpellCost } from '@/lib/game/store';
import { ELEMENTS, GUARDIANS, SPELLS_DEF, SKILLS_DEF, GOLEM_DEFS, GOLEM_VARIANTS, HOURS_PER_TICK } from '@/lib/game/constants';
import { fmt, fmtDec, getFloorElement, canAffordSpellCost, getFloorHPRegen } from '@/lib/game/store';
import { getActiveEquipmentSpells, getTotalDPS } from '@/lib/game/computed-stats';
import { formatSpellCost, getSpellCostColor, formatStudyTime } from '@/lib/game/formatting';
import { CraftingProgress, StudyProgress } from '@/components/game';
@@ -34,6 +34,11 @@ export function SpireTab({ store }: SpireTabProps) {
const floorMaxBarrier = store.floorMaxBarrier || 0;
const hasBarrier = floorBarrier > 0;
// HP Regeneration rate (all floors regen during combat)
// Guardian floors: 3% per hour, Non-guardian floors: 1% per hour
const floorHPRegenRate = getFloorHPRegen(store.currentFloor);
const isClimbing = store.currentAction === 'climb';
// Check if current floor is cleared (for respawn indicator)
const isFloorCleared = clearedFloors[store.currentFloor];
@@ -98,14 +103,19 @@ export function SpireTab({ store }: SpireTabProps) {
{isGuardianFloor && floorMaxBarrier > 0 && (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-400">🛡 Barrier</span>
<span className="text-gray-400 flex items-center gap-1">
<Shield className="w-3 h-3" />
Barrier
<span className="text-gray-500">(no regen)</span>
</span>
<span className="text-gray-500 game-mono">{fmt(floorBarrier)} / {fmt(floorMaxBarrier)}</span>
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300 bg-gray-500"
className="h-full rounded-full transition-all duration-300"
style={{
width: `${Math.max(0, (floorBarrier / floorMaxBarrier) * 100)}%`,
background: hasBarrier ? 'linear-gradient(90deg, #6B7280, #9CA3AF)' : 'linear-gradient(90deg, #374151, #4B5563)',
}}
/>
</div>
@@ -125,7 +135,15 @@ export function SpireTab({ store }: SpireTabProps) {
/>
</div>
<div className="flex justify-between text-xs text-gray-400 game-mono">
<span>{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP</span>
<span className="flex items-center gap-1">
{fmt(store.floorHP)} / {fmt(store.floorMaxHP)} HP
{isClimbing && floorHPRegenRate > 0 && (
<span className="text-green-500 flex items-center gap-0.5">
<Heart className="w-3 h-3 animate-pulse" />
+{fmt(floorHPRegenRate)}/hr
</span>
)}
</span>
<span>
{store.currentAction === 'climb' && (activeEquipmentSpells.length > 0 || activeGolemsOnFloor.length > 0) ? (
<span>