feat: restructure guardian progression system with dynamic element support
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s

This commit is contained in:
2026-05-29 17:18:13 +02:00
parent 644b76f16d
commit 71c68443c4
19 changed files with 757 additions and 446 deletions
@@ -34,7 +34,7 @@ function GuardianPactRow({ floor, isSigned, onForceSign, onRemove }: {
Floor {floor} | {guardian.pact}x multiplier
</div>
<div className="text-xs text-gray-500">
Element: {ELEMENTS[guardian.element]?.name || guardian.element}
Element: {guardian.element.map(el => ELEMENTS[el]?.name || el).join(' + ')}
</div>
</div>
<div className="flex gap-1">
@@ -88,7 +88,7 @@ export function PactDebugSection() {
...signedPactDetails,
[floor]: {
floor,
guardianId: guardian.element,
guardianId: guardian.element.join('+'),
signedAt: { day: useGameStore.getState().day, hour: useGameStore.getState().hour },
skillLevels: {} as Record<string, number>,
},
@@ -1,7 +1,5 @@
import { describe, it, expect } from 'vitest';
// ─── Test: SpireSummaryTab barrel export ───────────────────────────────────────
describe('SpireSummaryTab module structure', () => {
it('exports SpireSummaryTab from its module', async () => {
const mod = await import('./SpireSummaryTab');
@@ -15,8 +13,6 @@ describe('SpireSummaryTab module structure', () => {
});
});
// ─── Test: Barrel export includes SpireSummaryTab ──────────────────────────────
describe('Tab barrel export', () => {
it('includes SpireSummaryTab in the tabs index', async () => {
const mod = await import('@/components/game/tabs');
@@ -25,48 +21,49 @@ describe('Tab barrel export', () => {
});
});
// ─── Test: Guardian data ───────────────────────────────────────────────────────
describe('Guardian data', () => {
it('has 14 static guardians plus combo guardians', async () => {
it('has static guardians plus procedural guardians', async () => {
const { getAllGuardianFloors } = await import('@/lib/game/data/guardian-encounters');
const floors = getAllGuardianFloors();
// 14 static (10-140) + 10 combo (150-240) = 24 total
expect(floors.length).toBe(24);
// Static: 10-80 (8), 90-110 (3), 130-160 (4), 170-200 (4) = 19 static
// Procedural: 210-450 every 10 = 25 procedural
expect(floors.length).toBeGreaterThanOrEqual(19);
});
it('guardians are at expected floors for all tiers', async () => {
const { getGuardianForFloor } = await import('@/lib/game/data/guardian-encounters');
// Base: 10-70, Utility: 80, Compound: 90-110, Exotic: 120-140
for (const floor of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140]) {
// Base: 10-80, Composite: 90-110, Comp+Components: 130-160, Exotic: 170-200
for (const floor of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 130, 140, 150, 160, 170, 180, 190, 200]) {
expect(getGuardianForFloor(floor)).not.toBeNull();
}
});
it('follows elemental → compound → exotic progression', async () => {
it('follows element progression: base → composite → exotic', async () => {
const { getGuardianForFloor } = await import('@/lib/game/data/guardian-encounters');
expect(getGuardianForFloor(10)!.element).toBe('fire');
expect(getGuardianForFloor(20)!.element).toBe('water');
expect(getGuardianForFloor(30)!.element).toBe('air');
expect(getGuardianForFloor(40)!.element).toBe('earth');
expect(getGuardianForFloor(50)!.element).toBe('light');
expect(getGuardianForFloor(60)!.element).toBe('dark');
expect(getGuardianForFloor(70)!.element).toBe('death');
expect(getGuardianForFloor(80)!.element).toBe('transference');
expect(getGuardianForFloor(90)!.element).toBe('metal');
expect(getGuardianForFloor(100)!.element).toBe('sand');
expect(getGuardianForFloor(110)!.element).toBe('lightning');
expect(getGuardianForFloor(120)!.element).toBe('crystal');
expect(getGuardianForFloor(130)!.element).toBe('stellar');
expect(getGuardianForFloor(140)!.element).toBe('void');
// Elements are now arrays
expect(getGuardianForFloor(10)!.element).toEqual(['fire']);
expect(getGuardianForFloor(20)!.element).toEqual(['water']);
expect(getGuardianForFloor(30)!.element).toEqual(['air']);
expect(getGuardianForFloor(40)!.element).toEqual(['earth']);
expect(getGuardianForFloor(50)!.element).toEqual(['light']);
expect(getGuardianForFloor(60)!.element).toEqual(['dark']);
expect(getGuardianForFloor(70)!.element).toEqual(['death']);
expect(getGuardianForFloor(80)!.element).toEqual(['transference']);
expect(getGuardianForFloor(90)!.element).toEqual(['metal']);
expect(getGuardianForFloor(100)!.element).toEqual(['sand']);
expect(getGuardianForFloor(110)!.element).toEqual(['lightning']);
expect(getGuardianForFloor(170)!.element).toEqual(['crystal']);
expect(getGuardianForFloor(180)!.element).toEqual(['stellar']);
expect(getGuardianForFloor(190)!.element).toEqual(['void']);
});
it('all static guardians have required fields', async () => {
const { getGuardianForFloor } = await import('@/lib/game/data/guardian-encounters');
for (const floor of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140]) {
for (const floor of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 130, 140, 150, 160, 170, 180, 190, 200]) {
const def = getGuardianForFloor(floor)!;
expect(def.name).toBeTruthy();
expect(def.element).toBeTruthy();
expect(Array.isArray(def.element)).toBe(true);
expect(def.hp).toBeGreaterThan(0);
expect(def.color).toBeTruthy();
expect(def.boons).toBeInstanceOf(Array);
@@ -74,18 +71,16 @@ describe('Guardian data', () => {
}
});
it('all static guardians have unique elements', async () => {
it('all static guardians have unique element sets', async () => {
const { getGuardianForFloor } = await import('@/lib/game/data/guardian-encounters');
const floors = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140];
const elements = floors.map((f) => getGuardianForFloor(f)!.element);
const uniqueElements = new Set(elements);
expect(uniqueElements.size).toBe(elements.length);
const floors = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 130, 140, 150, 160, 170, 180, 190, 200];
const elementKeys = floors.map((f) => getGuardianForFloor(f)!.element.join(','));
const uniqueElements = new Set(elementKeys);
expect(uniqueElements.size).toBe(elementKeys.length);
});
});
// ─── Test: Combat store spire fields ───────────────────────────────────────────
describe('Combat store spire state', () => {
describe('Combat store spire fields', () => {
it('useCombatStore is importable', async () => {
const mod = await import('@/lib/game/stores');
expect(mod.useCombatStore).toBeDefined();
@@ -93,8 +88,6 @@ describe('Combat store spire state', () => {
});
});
// ─── Test: Floor element cycle ─────────────────────────────────────────────────
describe('Floor element cycle', () => {
it('FLOOR_ELEM_CYCLE has 7 elements', async () => {
const { FLOOR_ELEM_CYCLE } = await import('@/lib/game/constants');
@@ -103,20 +96,16 @@ describe('Floor element cycle', () => {
it('element opposites define expected pairs', async () => {
const { ELEMENT_OPPOSITES } = await import('@/lib/game/constants');
// Core pairs are symmetric
expect(ELEMENT_OPPOSITES['fire']).toBe('water');
expect(ELEMENT_OPPOSITES['water']).toBe('fire');
expect(ELEMENT_OPPOSITES['air']).toBe('earth');
expect(ELEMENT_OPPOSITES['earth']).toBe('air');
expect(ELEMENT_OPPOSITES['light']).toBe('dark');
expect(ELEMENT_OPPOSITES['dark']).toBe('light');
// Lightning has an asymmetric opposite (grounding)
expect(ELEMENT_OPPOSITES['lightning']).toBe('earth');
});
});
// ─── Test: File size limit ─────────────────────────────────────────────────────
describe('File size limits (400 lines max)', () => {
it('SpireSummaryTab.tsx is under 400 lines', async () => {
const fs = await import('fs');
+5 -5
View File
@@ -77,7 +77,7 @@ function FloorProgressBar({ maxFloor, clearedFloors }: { maxFloor: number; clear
}`}
title={
getGuardianForFloor(floor)
? `Floor ${floor}${getGuardianForFloor(floor)!.name} (${getGuardianForFloor(floor)!.element})`
? `Floor ${floor}${getGuardianForFloor(floor)!.name} (${getGuardianForFloor(floor)!.element.join(' + ')})`
: `Floor ${floor}${isCleared ? ' (cleared)' : ''}`
}
>
@@ -149,7 +149,7 @@ function StatCell({ value, label, color }: { value: number | string; label: stri
// ─── Next Guardian Card ──────────────────────────────────────────────────────
function NextGuardianCard({ nextGuardian, nextGuardianData }: { nextGuardian: number; nextGuardianData: GuardianDef }) {
const counterElement = getCounterElement(nextGuardianData.element);
const counterElement = getCounterElement(nextGuardianData.element[0]);
const nextFloorElement = FLOOR_ELEM_CYCLE[(nextGuardian - 1) % FLOOR_ELEM_CYCLE.length];
return (
@@ -176,9 +176,9 @@ function NextGuardianCard({ nextGuardian, nextGuardianData }: { nextGuardian: nu
<Badge
variant="outline"
className="text-xs"
style={{ borderColor: getElementColor(nextGuardianData.element), color: getElementColor(nextGuardianData.element) }}
style={{ borderColor: getElementColor(nextGuardianData.element[0]), color: getElementColor(nextGuardianData.element[0]) }}
>
{nextGuardianData.element}
{nextGuardianData.element.join(' + ')}
</Badge>
<span className="text-xs text-gray-500">HP: {fmt(nextGuardianData.hp)}</span>
{nextGuardianData.armor && (
@@ -287,7 +287,7 @@ function GuardianRosterItem({ floor, guardian, isDefeated }: { floor: number; gu
color: guardian.color,
}}
>
{guardian.element}
{guardian.element.join(' + ')}
</span>
<span className="text-[10px] text-gray-500">HP: {fmt(guardian.hp)}</span>
</div>
@@ -26,9 +26,8 @@ interface ElementDisplay {
color: string;
}
function getElementDisplays(element: string): ElementDisplay[] {
// Combo guardians have elements like "fire+water"
const parts = element.split('+');
function getElementDisplays(element: string | string[]): ElementDisplay[] {
const parts = Array.isArray(element) ? element : element.split('+');
return parts.map((el) => {
const def = ELEMENTS[el];
return {
@@ -79,7 +78,7 @@ export const GuardianCard: React.FC<GuardianCardProps> = React.memo(({
// Build element label: single element name, or "Fire + Water" for combos
const elementLabel = isCombo
? elemDisplays.map(e => e.name).join(' + ')
: elemDisplays[0]?.name ?? guardian.element;
: elemDisplays[0]?.name ?? guardian.element.join(' + ');
return (
<DebugName name="GuardianPactsComponents">