Major bug fixes and system cleanup
Some checks failed
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m16s
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:
@@ -232,7 +232,6 @@ export default function ManaLoopGame() {
|
||||
totalSpellsCast: store.totalSpellsCast,
|
||||
totalDamageDealt: store.totalDamageDealt,
|
||||
totalCraftsCompleted: store.totalCraftsCompleted,
|
||||
combo: store.combo,
|
||||
}}
|
||||
/>
|
||||
</DebugName>
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
133
src/lib/game/__tests__/bug-fixes.test.ts
Normal file
133
src/lib/game/__tests__/bug-fixes.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
@@ -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()}`;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user