feat: restructure guardian progression system with dynamic element support
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m20s
This commit is contained in:
@@ -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">
|
||||
@@ -112,7 +112,7 @@ export function PactDebug() {
|
||||
...signedPactDetails,
|
||||
[floor]: {
|
||||
floor,
|
||||
guardianId: guardian.element,
|
||||
guardianId: guardian.element.join('+'),
|
||||
signedAt: { day: useGameStore.getState().day, hour: useGameStore.getState().hour },
|
||||
skillLevels: {} as Record<string, number>,
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user