Major bug fixes and system cleanup
Some checks failed
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m16s

- Fix enchantment capacity validation to respect equipment capacity limits
- Remove combo system (no longer used)
- Remove AUDIT_REPORT.md and GAME_SYSTEMS_ANALYSIS.md files
- Add tests for capacity validation, attunement unlocking, and floor HP
- Remove combo-related achievements
- Fix AchievementsDisplay to not reference combo state
- Add capacity display showing current/max in enchantment design UI
- Prevent designs that exceed equipment capacity from being created
This commit is contained in:
Z User
2026-03-29 19:08:06 +00:00
parent 300e43f8be
commit 522079e011
11 changed files with 162 additions and 1019 deletions

View File

@@ -232,7 +232,6 @@ export default function ManaLoopGame() {
totalSpellsCast: store.totalSpellsCast,
totalDamageDealt: store.totalDamageDealt,
totalCraftsCompleted: store.totalCraftsCompleted,
combo: store.combo,
}}
/>
</DebugName>

View File

@@ -13,7 +13,7 @@ import { GameState } from '@/lib/game/types';
interface AchievementsProps {
achievements: AchievementState;
gameState: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'totalSpellsCast' | 'totalDamageDealt' | 'totalCraftsCompleted' | 'combo'>;
gameState: Pick<GameState, 'maxFloorReached' | 'totalManaGathered' | 'signedPacts' | 'totalSpellsCast' | 'totalDamageDealt' | 'totalCraftsCompleted'>;
}
export function AchievementsDisplay({ achievements, gameState }: AchievementsProps) {
@@ -39,8 +39,6 @@ export function AchievementsDisplay({ achievements, gameState }: AchievementsPro
: gameState.maxFloorReached;
}
return gameState.maxFloorReached;
case 'combo':
return gameState.combo?.maxCombo || 0;
case 'spells':
return gameState.totalSpellsCast || 0;
case 'damage':

View File

@@ -1,143 +0,0 @@
'use client';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Zap, Flame, Sparkles } from 'lucide-react';
import type { ComboState } from '@/lib/game/types';
import { ELEMENTS } from '@/lib/game/constants';
interface ComboMeterProps {
combo: ComboState;
isClimbing: boolean;
}
export function ComboMeter({ combo, isClimbing }: ComboMeterProps) {
const comboPercent = Math.min(100, combo.count);
const multiplierPercent = Math.min(100, ((combo.multiplier - 1) / 2) * 100); // Max 300% = 200% bonus
// Combo tier names
const getComboTier = (count: number): { name: string; color: string } => {
if (count >= 100) return { name: 'LEGENDARY', color: 'text-amber-400' };
if (count >= 75) return { name: 'Master', color: 'text-purple-400' };
if (count >= 50) return { name: 'Expert', color: 'text-blue-400' };
if (count >= 25) return { name: 'Adept', color: 'text-green-400' };
if (count >= 10) return { name: 'Novice', color: 'text-cyan-400' };
return { name: 'Building...', color: 'text-gray-400' };
};
const tier = getComboTier(combo.count);
const hasElementChain = combo.elementChain.length === 3 && new Set(combo.elementChain).size === 3;
if (!isClimbing && combo.count === 0) {
return null;
}
return (
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs flex items-center gap-2">
<Zap className="w-4 h-4" />
Combo Meter
{combo.count >= 10 && (
<Badge className={`ml-auto ${tier.color} bg-gray-800`}>
{tier.name}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* Combo Count */}
<div className="space-y-1">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Hits</span>
<span className={`font-bold ${tier.color}`}>
{combo.count}
{combo.maxCombo > combo.count && (
<span className="text-gray-500 text-xs ml-2">max: {combo.maxCombo}</span>
)}
</span>
</div>
<Progress
value={comboPercent}
className="h-2 bg-gray-800"
/>
</div>
{/* Multiplier */}
<div className="space-y-1">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Multiplier</span>
<span className="font-bold text-amber-400">
{combo.multiplier.toFixed(2)}x
</span>
</div>
<div className="h-2 bg-gray-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${multiplierPercent}%`,
background: `linear-gradient(90deg, #F59E0B, #EF4444)`,
}}
/>
</div>
</div>
{/* Element Chain */}
{combo.elementChain.length > 0 && (
<div className="space-y-1">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Element Chain</span>
{hasElementChain && (
<span className="text-green-400 text-xs">+25% bonus!</span>
)}
</div>
<div className="flex gap-1">
{combo.elementChain.map((elem, i) => {
const elemDef = ELEMENTS[elem];
return (
<div
key={i}
className="w-8 h-8 rounded border flex items-center justify-center text-xs"
style={{
borderColor: elemDef?.color || '#60A5FA',
backgroundColor: `${elemDef?.color}20`,
color: elemDef?.color || '#60A5FA',
}}
>
{elemDef?.sym || '?'}
</div>
);
})}
{/* Empty slots */}
{Array.from({ length: 3 - combo.elementChain.length }).map((_, i) => (
<div
key={`empty-${i}`}
className="w-8 h-8 rounded border border-gray-700 bg-gray-800/50 flex items-center justify-center text-gray-600"
>
?
</div>
))}
</div>
</div>
)}
{/* Decay Warning */}
{isClimbing && combo.count > 0 && combo.decayTimer <= 3 && (
<div className="text-xs text-red-400 flex items-center gap-1">
<Flame className="w-3 h-3" />
Combo decaying soon!
</div>
)}
{/* Not climbing warning */}
{!isClimbing && combo.count > 0 && (
<div className="text-xs text-amber-400 flex items-center gap-1">
<Sparkles className="w-3 h-3" />
Resume climbing to maintain combo
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -89,6 +89,10 @@ export function CraftingTab({ store }: CraftingTabProps) {
0
);
// Get capacity limit for selected equipment type
const selectedEquipmentCapacity = selectedEquipmentType ? EQUIPMENT_TYPES[selectedEquipmentType]?.baseCapacity || 0 : 0;
const isOverCapacity = selectedEquipmentType ? designCapacityCost > selectedEquipmentCapacity : false;
// Calculate design time
const designTime = selectedEffects.reduce((total, eff) => total + 0.5 * eff.stacks, 1);
@@ -299,8 +303,8 @@ export function CraftingTab({ store }: CraftingTabProps) {
/>
<div className="flex justify-between text-sm">
<span>Total Capacity:</span>
<span className={designCapacityCost > 100 ? 'text-red-400' : 'text-green-400'}>
{designCapacityCost.toFixed(0)}
<span className={isOverCapacity ? 'text-red-400' : 'text-green-400'}>
{designCapacityCost.toFixed(0)} / {selectedEquipmentCapacity}
</span>
</div>
<div className="flex justify-between text-sm text-gray-400">
@@ -309,10 +313,10 @@ export function CraftingTab({ store }: CraftingTabProps) {
</div>
<Button
className="w-full"
disabled={!designName || selectedEffects.length === 0}
disabled={!designName || selectedEffects.length === 0 || isOverCapacity}
onClick={handleCreateDesign}
>
Start Design ({designTime.toFixed(1)}h)
{isOverCapacity ? 'Over Capacity!' : `Start Design (${designTime.toFixed(1)}h)`}
</Button>
</div>
</>

View File

@@ -0,0 +1,133 @@
import { describe, it, expect } from 'vitest';
import { calculateEffectCapacityCost, ENCHANTMENT_EFFECTS } from '../data/enchantment-effects';
import { EQUIPMENT_TYPES } from '../data/equipment';
import { ATTUNEMENTS_DEF, getAttunementConversionRate } from '../data/attunements';
describe('Enchantment Capacity Validation', () => {
it('should calculate capacity cost for single stack effects', () => {
// Mana Bolt spell effect has base capacity cost of 50
const cost = calculateEffectCapacityCost('spell_manaBolt', 1, 0);
expect(cost).toBe(50);
});
it('should apply scaling for multiple stacks', () => {
// damage_5 has base cost 15, each additional stack costs 20% more
const cost1 = calculateEffectCapacityCost('damage_5', 1, 0);
const cost2 = calculateEffectCapacityCost('damage_5', 2, 0);
// First stack: 15
// Second stack: 15 * 1.2 = 18
// Total: 33
expect(cost1).toBe(15);
expect(cost2).toBe(Math.floor(15 + 15 * 1.2));
});
it('should apply efficiency bonus to reduce cost', () => {
const costWithoutEfficiency = calculateEffectCapacityCost('spell_manaBolt', 1, 0);
const costWithEfficiency = calculateEffectCapacityCost('spell_manaBolt', 1, 0.1); // 10% reduction
expect(costWithEfficiency).toBe(Math.floor(costWithoutEfficiency * 0.9));
});
it('should respect equipment base capacity', () => {
// Civilian Shirt has base capacity 30
const shirt = EQUIPMENT_TYPES['civilianShirt'];
expect(shirt.baseCapacity).toBe(30);
// Basic Staff has base capacity 50
const staff = EQUIPMENT_TYPES['basicStaff'];
expect(staff.baseCapacity).toBe(50);
});
it('should reject enchantment designs exceeding equipment capacity', () => {
// Mana Bolt spell effect costs 50 capacity
// Civilian Shirt only has 30 capacity
const manaBoltCost = calculateEffectCapacityCost('spell_manaBolt', 1, 0);
const shirtCapacity = EQUIPMENT_TYPES['civilianShirt'].baseCapacity;
expect(manaBoltCost).toBeGreaterThan(shirtCapacity);
});
});
describe('Attunement Mana Type Unlocking', () => {
it('should define primary mana types for attunements', () => {
// Enchanter should have transference as primary mana
const enchanter = ATTUNEMENTS_DEF['enchanter'];
expect(enchanter.primaryManaType).toBe('transference');
// Fabricator should have earth as primary mana
const fabricator = ATTUNEMENTS_DEF['fabricator'];
expect(fabricator.primaryManaType).toBe('earth');
});
it('should have conversion rates for attunements with primary mana', () => {
// Enchanter should have a conversion rate
const enchanter = ATTUNEMENTS_DEF['enchanter'];
expect(enchanter.conversionRate).toBeGreaterThan(0);
// Get scaled conversion rate at level 1
const level1Rate = getAttunementConversionRate('enchanter', 1);
expect(level1Rate).toBe(enchanter.conversionRate);
// Higher level should have higher rate
const level5Rate = getAttunementConversionRate('enchanter', 5);
expect(level5Rate).toBeGreaterThan(level1Rate);
});
it('should have raw mana regen for all attunements', () => {
Object.values(ATTUNEMENTS_DEF).forEach(attunement => {
expect(attunement.rawManaRegen).toBeGreaterThanOrEqual(0);
});
});
});
describe('Floor HP State', () => {
it('should have getFloorMaxHP function that returns positive values', async () => {
const { getFloorMaxHP } = await import('../computed-stats');
for (let floor = 1; floor <= 100; floor++) {
const hp = getFloorMaxHP(floor);
expect(hp).toBeGreaterThan(0);
}
});
it('should scale HP correctly with floor progression', async () => {
const { getFloorMaxHP } = await import('../computed-stats');
const hp1 = getFloorMaxHP(1);
const hp10 = getFloorMaxHP(10);
const hp50 = getFloorMaxHP(50);
const hp100 = getFloorMaxHP(100);
expect(hp10).toBeGreaterThan(hp1);
expect(hp50).toBeGreaterThan(hp10);
expect(hp100).toBeGreaterThan(hp50);
});
});
describe('Element State', () => {
it('should have utility elements defined', async () => {
const { ELEMENTS } = await import('../constants');
// Check that utility elements exist
expect(ELEMENTS['mental']).toBeDefined();
expect(ELEMENTS['transference']).toBeDefined();
expect(ELEMENTS['force']).toBeDefined();
// Check categories
expect(ELEMENTS['mental'].cat).toBe('utility');
expect(ELEMENTS['transference'].cat).toBe('utility');
});
it('should have composite elements with recipes', async () => {
const { ELEMENTS } = await import('../constants');
// Blood is life + water
expect(ELEMENTS['blood'].cat).toBe('composite');
expect(ELEMENTS['blood'].recipe).toEqual(['life', 'water']);
// Metal is fire + earth
expect(ELEMENTS['metal'].cat).toBe('composite');
expect(ELEMENTS['metal'].recipe).toEqual(['fire', 'earth']);
});
});

View File

@@ -274,8 +274,11 @@ export function createCraftingSlice(
const enchantingLevel = state.skills.enchanting || 0;
if (enchantingLevel < 1) return false;
// Validate effects for equipment category
const category = getEquipmentCategory(equipmentTypeId);
// Get equipment type and category
const equipType = EQUIPMENT_TYPES[equipmentTypeId];
if (!equipType) return false;
const category = equipType.category;
if (!category) return false;
for (const eff of effects) {
@@ -288,6 +291,11 @@ export function createCraftingSlice(
// Calculate capacity cost
const efficiencyBonus = (state.skills.efficientEnchant || 0) * 0.05;
const totalCapacityCost = calculateDesignCapacityCost(effects, efficiencyBonus);
// Validate capacity - design must fit within equipment capacity
if (totalCapacityCost > equipType.baseCapacity) {
return false; // Design exceeds equipment capacity
}
// Create design ID
const designId = `design_${Date.now()}`;

View File

@@ -53,32 +53,6 @@ export const ACHIEVEMENTS: Record<string, AchievementDef> = {
reward: { insight: 500, manaBonus: 200, damageBonus: 0.25, title: 'Apex Climber' },
},
// ─── Combo Achievements ───
comboStarter: {
id: 'comboStarter',
name: 'Combo Starter',
desc: 'Reach a 10-hit combo',
category: 'combat',
requirement: { type: 'combo', value: 10 },
reward: { insight: 15 },
},
comboMaster: {
id: 'comboMaster',
name: 'Combo Master',
desc: 'Reach a 50-hit combo',
category: 'combat',
requirement: { type: 'combo', value: 50 },
reward: { insight: 50, damageBonus: 0.05 },
},
comboLegend: {
id: 'comboLegend',
name: 'Combo Legend',
desc: 'Reach a 100-hit combo',
category: 'combat',
requirement: { type: 'combo', value: 100 },
reward: { insight: 150, damageBonus: 0.1, title: 'Combo Legend' },
},
// ─── Damage Achievements ───
hundredDamage: {
id: 'hundredDamage',

View File

@@ -466,13 +466,6 @@ function makeInitial(overrides: Partial<GameState> = {}): GameState {
activeSpell: 'manaBolt',
currentAction: 'meditate',
castProgress: 0,
combo: {
count: 0,
maxCombo: 0,
multiplier: 1,
elementChain: [],
decayTimer: 0,
},
spells: startSpells,
skills: overrides.skills || {},
@@ -1464,6 +1457,16 @@ export const useGameStore = create<GameStore>()(
if (eff.stacks > effectDef.maxStacks) return false;
}
// Validate capacity - design must fit within equipment capacity
const efficiencyBonus = (state.skills.efficientEnchant || 0) * 0.05;
const totalCapacityCost = effects.reduce(
(total, eff) => total + calculateEffectCapacityCost(eff.effectId, eff.stacks, efficiencyBonus),
0
);
if (totalCapacityCost > type.baseCapacity) {
return false; // Design exceeds equipment capacity
}
// Calculate design time
let designTime = 1;
for (const eff of effects) {

View File

@@ -317,15 +317,6 @@ export interface StudyTarget {
required: number; // Total hours needed
}
// Combo state for combat
export interface ComboState {
count: number; // Current combo hits
maxCombo: number; // Highest combo this session
multiplier: number; // Current damage multiplier
elementChain: string[]; // Last 3 elements used
decayTimer: number; // Hours until decay starts
}
export interface GameState {
// Time
day: number;
@@ -355,7 +346,6 @@ export interface GameState {
activeSpell: string;
currentAction: GameAction;
castProgress: number; // Progress towards next spell cast (0-1)
combo: ComboState; // Combat combo tracking
// Spells
spells: Record<string, SpellState>;