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">
|
||||
|
||||
@@ -5,68 +5,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
generateGuardianName,
|
||||
generateComboGuardianName,
|
||||
getGuardianForFloor,
|
||||
getExtendedGuardian,
|
||||
getAllGuardianFloors,
|
||||
} from '../data/guardian-encounters';
|
||||
|
||||
describe('generateGuardianName', () => {
|
||||
it('should return the same name for the same element and floor', () => {
|
||||
const name1 = generateGuardianName('fire', 90);
|
||||
const name2 = generateGuardianName('fire', 90);
|
||||
it('should return the same name for the same elements and floor', () => {
|
||||
const name1 = generateGuardianName(['fire'], 90);
|
||||
const name2 = generateGuardianName(['fire'], 90);
|
||||
expect(name1).toBe(name2);
|
||||
});
|
||||
|
||||
it('should return different names for different elements', () => {
|
||||
const nameFire = generateGuardianName('fire', 90);
|
||||
const nameWater = generateGuardianName('water', 90);
|
||||
const nameFire = generateGuardianName(['fire'], 90);
|
||||
const nameWater = generateGuardianName(['water'], 90);
|
||||
expect(nameFire).not.toBe(nameWater);
|
||||
});
|
||||
|
||||
it('should return different names for different floors', () => {
|
||||
const name90 = generateGuardianName('fire', 90);
|
||||
const name100 = generateGuardianName('fire', 100);
|
||||
const name90 = generateGuardianName(['fire'], 90);
|
||||
const name100 = generateGuardianName(['fire'], 100);
|
||||
expect(name90).not.toBe(name100);
|
||||
});
|
||||
|
||||
it('should produce non-empty names', () => {
|
||||
const name = generateGuardianName('metal', 90);
|
||||
const name = generateGuardianName(['metal'], 90);
|
||||
expect(name).toBeTruthy();
|
||||
expect(name.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should follow the "Prefix the Title" pattern', () => {
|
||||
const name = generateGuardianName('crystal', 120);
|
||||
it('should follow the "Prefix the Title" pattern for single element', () => {
|
||||
const name = generateGuardianName(['crystal'], 120);
|
||||
expect(name).toMatch(/^[\w]+ the [\w]+$/);
|
||||
});
|
||||
|
||||
it('should combine prefixes with hyphen for multi-element', () => {
|
||||
const name = generateGuardianName(['fire', 'water'], 150);
|
||||
expect(name).toMatch(/^[\w]+-[\w]+ the [\w]+$/);
|
||||
});
|
||||
|
||||
it('should produce "Unknown the <title>" for unknown element', () => {
|
||||
const name = generateGuardianName('nonexistent', 90);
|
||||
const name = generateGuardianName(['nonexistent'], 90);
|
||||
expect(name).toMatch(/^Unknown the [\w]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateComboGuardianName', () => {
|
||||
it('should return the same name for the same elements and floor', () => {
|
||||
const name1 = generateComboGuardianName(['fire', 'water'], 150);
|
||||
const name2 = generateComboGuardianName(['fire', 'water'], 150);
|
||||
expect(name1).toBe(name2);
|
||||
});
|
||||
|
||||
it('should return different names for different floors', () => {
|
||||
const name150 = generateComboGuardianName(['fire', 'water'], 150);
|
||||
const name160 = generateComboGuardianName(['fire', 'water'], 160);
|
||||
expect(name150).not.toBe(name160);
|
||||
});
|
||||
|
||||
it('should combine both element prefixes with hyphen', () => {
|
||||
const name = generateComboGuardianName(['fire', 'water'], 150);
|
||||
expect(name).toMatch(/^[\w]+-[\w]+ the [\w]+$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuardianForFloor', () => {
|
||||
it('should return stable names for compound guardians (floors 90-110)', () => {
|
||||
it('should return stable names for composite guardians (floors 90-110)', () => {
|
||||
const g90a = getGuardianForFloor(90);
|
||||
const g90b = getGuardianForFloor(90);
|
||||
expect(g90a).not.toBeNull();
|
||||
@@ -82,8 +67,8 @@ describe('getGuardianForFloor', () => {
|
||||
expect(g110a!.name).toBe(g110b!.name);
|
||||
});
|
||||
|
||||
it('should return stable names for exotic guardians (floors 120-140)', () => {
|
||||
for (const floor of [120, 130, 140]) {
|
||||
it('should return stable names for exotic guardians (floors 170-200)', () => {
|
||||
for (const floor of [170, 180, 190, 200]) {
|
||||
const a = getGuardianForFloor(floor);
|
||||
const b = getGuardianForFloor(floor);
|
||||
expect(a).not.toBeNull();
|
||||
@@ -98,31 +83,39 @@ describe('getGuardianForFloor', () => {
|
||||
|
||||
it('should return different names for different floors', () => {
|
||||
const g90 = getGuardianForFloor(90);
|
||||
const g120 = getGuardianForFloor(120);
|
||||
expect(g90!.name).not.toBe(g120!.name);
|
||||
const g170 = getGuardianForFloor(170);
|
||||
expect(g90!.name).not.toBe(g170!.name);
|
||||
});
|
||||
|
||||
it('should return arrays for element field', () => {
|
||||
const g10 = getGuardianForFloor(10);
|
||||
expect(Array.isArray(g10!.element)).toBe(true);
|
||||
expect(g10!.element).toContain('fire');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExtendedGuardian', () => {
|
||||
it('should return stable names for combo guardians (floor 150+)', () => {
|
||||
const a = getExtendedGuardian(150);
|
||||
const b = getExtendedGuardian(150);
|
||||
expect(a).not.toBeNull();
|
||||
expect(b).not.toBeNull();
|
||||
expect(a!.name).toBe(b!.name);
|
||||
describe('getAllGuardianFloors', () => {
|
||||
it('should include all static guardian floors', () => {
|
||||
const floors = getAllGuardianFloors();
|
||||
for (const f of [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 130, 140, 150, 160, 170, 180, 190, 200]) {
|
||||
expect(floors).toContain(f);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return different names for different combo floors', () => {
|
||||
const g150 = getExtendedGuardian(150);
|
||||
const g160 = getExtendedGuardian(160);
|
||||
expect(g150).not.toBeNull();
|
||||
expect(g160).not.toBeNull();
|
||||
expect(g150!.name).not.toBe(g160!.name);
|
||||
it('should include procedural guardian floors 210-450', () => {
|
||||
const floors = getAllGuardianFloors();
|
||||
expect(floors).toContain(210);
|
||||
expect(floors).toContain(250);
|
||||
expect(floors).toContain(300);
|
||||
expect(floors).toContain(400);
|
||||
expect(floors).toContain(450);
|
||||
});
|
||||
|
||||
it('should return null for non-guardian floors', () => {
|
||||
expect(getExtendedGuardian(151)).toBeNull();
|
||||
expect(getExtendedGuardian(155)).toBeNull();
|
||||
it('should be sorted', () => {
|
||||
const floors = getAllGuardianFloors();
|
||||
for (let i = 1; i < floors.length; i++) {
|
||||
expect(floors[i]).toBeGreaterThan(floors[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should produce unique names across many repeated calls', () => {
|
||||
@@ -131,7 +124,6 @@ describe('getExtendedGuardian', () => {
|
||||
const g = getGuardianForFloor(90);
|
||||
names.add(g!.name);
|
||||
}
|
||||
// All 100 calls should produce exactly 1 unique name
|
||||
expect(names.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('generateFloorState', () => {
|
||||
const g10 = getGuardianForFloor(10)!;
|
||||
expect(state.enemies[0].name).toBe(g10.name);
|
||||
expect(state.enemies[0].hp).toBe(g10.hp);
|
||||
expect(state.enemies[0].element).toBe(g10.element);
|
||||
expect(state.enemies[0].element).toBe(g10.element.join('+'));
|
||||
});
|
||||
|
||||
it('should generate combat state for non-guardian floor with combat', () => {
|
||||
|
||||
@@ -9,14 +9,14 @@ import {
|
||||
getSpireRoomTypeDisplay,
|
||||
SPIRE_CONFIG,
|
||||
} from '../utils/spire-utils';
|
||||
import { isGuardianFloor, getExtendedGuardian, getGuardianForFloor, getGuardianHP, generateGuardianName, generateComboGuardianName, ALL_GUARDIAN_FLOORS } from '../data/guardian-encounters';
|
||||
import { isGuardianFloor, getGuardianForFloor, getGuardianHP, generateGuardianName, getAllGuardianFloors } from '../data/guardian-encounters';
|
||||
|
||||
// ─── Spire Utils ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getRoomsForFloor', () => {
|
||||
it('should return at least minRoomsPerFloor for non-guardian floors', () => {
|
||||
for (let floor = 1; floor <= 50; floor++) {
|
||||
if (floor % 10 === 0) continue; // Skip guardian floors
|
||||
if (floor % 10 === 0) continue;
|
||||
const rooms = getRoomsForFloor(floor);
|
||||
expect(rooms).toBeGreaterThanOrEqual(SPIRE_CONFIG.minRoomsPerFloor);
|
||||
expect(rooms).toBeLessThanOrEqual(SPIRE_CONFIG.maxRoomsPerFloor + 5);
|
||||
@@ -46,16 +46,17 @@ describe('generateSpireRoomType', () => {
|
||||
it('should return combat for first room on non-guardian floors', () => {
|
||||
for (const floor of [1, 5, 15, 25]) {
|
||||
const roomType = generateSpireRoomType(floor, 0, 10);
|
||||
// First room may be combat, swarm, or speed depending on random
|
||||
expect(['combat', 'swarm', 'speed']).toContain(roomType);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return combat for first room on guardian floors (not last room)', () => {
|
||||
// Floor 50 is a guardian floor, but first room should still be combat
|
||||
const roomType = generateSpireRoomType(50, 0, 10);
|
||||
// First room on guardian floor should not be 'guardian' (last room) and may be combat or swarm depending on random
|
||||
expect(['combat', 'swarm']).toContain(roomType);
|
||||
it('should return valid room type for first room on guardian floors (not last room)', () => {
|
||||
// First room on non-last position should never be 'guardian'
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const roomType = generateSpireRoomType(50, 0, 10);
|
||||
expect(['combat', 'swarm', 'speed']).toContain(roomType);
|
||||
expect(roomType).not.toBe('guardian');
|
||||
}
|
||||
});
|
||||
|
||||
it('should return valid room types', () => {
|
||||
@@ -83,9 +84,7 @@ describe('generateSpireFloorState', () => {
|
||||
});
|
||||
|
||||
it('should generate swarm floor with multiple enemies', () => {
|
||||
// Force swarm by using a non-special room index
|
||||
const state = generateSpireFloorState(20, 1, 10);
|
||||
// Room type depends on random, but enemies should be valid
|
||||
if (state.roomType === 'swarm') {
|
||||
expect(state.enemies.length).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
@@ -122,7 +121,6 @@ describe('getSpireEnemyBarrier', () => {
|
||||
for (let floor = 15; floor <= 100; floor++) {
|
||||
const barrier = getSpireEnemyBarrier(floor, 'fire');
|
||||
expect(barrier).toBeGreaterThanOrEqual(0);
|
||||
// Use toBeLessThan with a small tolerance for floating point precision
|
||||
expect(barrier).toBeLessThanOrEqual(0.3000000001);
|
||||
}
|
||||
});
|
||||
@@ -182,53 +180,52 @@ describe('isGuardianFloor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExtendedGuardian (procedural combo guardians)', () => {
|
||||
it('should return combo guardians for floors 150+', () => {
|
||||
const g150 = getExtendedGuardian(150);
|
||||
expect(g150).not.toBeNull();
|
||||
expect(g150!.element).toContain('+');
|
||||
});
|
||||
|
||||
it('should return null for floors below 150', () => {
|
||||
expect(getExtendedGuardian(1)).toBeNull();
|
||||
expect(getExtendedGuardian(15)).toBeNull();
|
||||
expect(getExtendedGuardian(95)).toBeNull();
|
||||
expect(getExtendedGuardian(100)).toBeNull();
|
||||
expect(getExtendedGuardian(140)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGuardianForFloor (unified lookup)', () => {
|
||||
it('should return base element guardians for floors 10-70', () => {
|
||||
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(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']);
|
||||
});
|
||||
|
||||
it('should return utility guardian for floor 80', () => {
|
||||
expect(getGuardianForFloor(80)!.element).toBe('transference');
|
||||
expect(getGuardianForFloor(80)!.element).toEqual(['transference']);
|
||||
});
|
||||
|
||||
it('should return compound guardians for floors 90-110', () => {
|
||||
expect(getGuardianForFloor(90)!.element).toBe('metal');
|
||||
expect(getGuardianForFloor(100)!.element).toBe('sand');
|
||||
expect(getGuardianForFloor(110)!.element).toBe('lightning');
|
||||
it('should return composite guardians for floors 90-110', () => {
|
||||
expect(getGuardianForFloor(90)!.element).toEqual(['metal']);
|
||||
expect(getGuardianForFloor(100)!.element).toEqual(['sand']);
|
||||
expect(getGuardianForFloor(110)!.element).toEqual(['lightning']);
|
||||
});
|
||||
|
||||
it('should return exotic guardians for floors 120-140', () => {
|
||||
expect(getGuardianForFloor(120)!.element).toBe('crystal');
|
||||
expect(getGuardianForFloor(130)!.element).toBe('stellar');
|
||||
expect(getGuardianForFloor(140)!.element).toBe('void');
|
||||
it('should return multi-element guardians for tier 3 floors', () => {
|
||||
expect(getGuardianForFloor(130)!.element).toEqual(['metal', 'fire', 'earth']);
|
||||
expect(getGuardianForFloor(140)!.element).toEqual(['sand', 'earth', 'water']);
|
||||
expect(getGuardianForFloor(150)!.element).toEqual(['lightning', 'fire', 'air']);
|
||||
});
|
||||
|
||||
it('should return combo guardians for floors 150+', () => {
|
||||
const g150 = getGuardianForFloor(150);
|
||||
expect(g150).not.toBeNull();
|
||||
expect(g150!.element).toContain('+');
|
||||
it('should return exotic guardians for floors 170-200', () => {
|
||||
expect(getGuardianForFloor(170)!.element).toEqual(['crystal']);
|
||||
expect(getGuardianForFloor(180)!.element).toEqual(['stellar']);
|
||||
expect(getGuardianForFloor(190)!.element).toEqual(['void']);
|
||||
});
|
||||
|
||||
it('should return multi-element exotic convergence for floor 200', () => {
|
||||
const g200 = getGuardianForFloor(200);
|
||||
expect(g200).not.toBeNull();
|
||||
expect(g200!.element.length).toBeGreaterThan(1);
|
||||
expect(g200!.element).toContain('crystal');
|
||||
expect(g200!.element).toContain('stellar');
|
||||
expect(g200!.element).toContain('void');
|
||||
});
|
||||
|
||||
it('should return guardians for procedural floors 210+', () => {
|
||||
const g210 = getGuardianForFloor(210);
|
||||
expect(g210).not.toBeNull();
|
||||
expect(g210!.element.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should return null for non-guardian floors', () => {
|
||||
@@ -255,36 +252,38 @@ describe('getGuardianHP', () => {
|
||||
describe('generateGuardianName', () => {
|
||||
it('should generate non-empty names', () => {
|
||||
for (const element of ['fire', 'water', 'air', 'earth', 'light', 'dark', 'death']) {
|
||||
const name = generateGuardianName(element);
|
||||
const name = generateGuardianName([element]);
|
||||
expect(name).toBeTruthy();
|
||||
expect(name.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should include a title', () => {
|
||||
const name = generateGuardianName('fire');
|
||||
const name = generateGuardianName(['fire']);
|
||||
expect(name).toContain(' the ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateComboGuardianName', () => {
|
||||
it('should combine two element prefixes', () => {
|
||||
const name = generateComboGuardianName(['fire', 'water']);
|
||||
expect(name).toContain(' the ');
|
||||
expect(name.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ALL_GUARDIAN_FLOORS', () => {
|
||||
describe('getAllGuardianFloors', () => {
|
||||
it('should include base guardian floors', () => {
|
||||
expect(ALL_GUARDIAN_FLOORS).toContain(10);
|
||||
expect(ALL_GUARDIAN_FLOORS).toContain(20);
|
||||
expect(ALL_GUARDIAN_FLOORS).toContain(100);
|
||||
const floors = getAllGuardianFloors();
|
||||
expect(floors).toContain(10);
|
||||
expect(floors).toContain(20);
|
||||
expect(floors).toContain(100);
|
||||
});
|
||||
|
||||
it('should be sorted', () => {
|
||||
for (let i = 1; i < ALL_GUARDIAN_FLOORS.length; i++) {
|
||||
expect(ALL_GUARDIAN_FLOORS[i]).toBeGreaterThan(ALL_GUARDIAN_FLOORS[i - 1]);
|
||||
const floors = getAllGuardianFloors();
|
||||
for (let i = 1; i < floors.length; i++) {
|
||||
expect(floors[i]).toBeGreaterThan(floors[i - 1]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should include procedural floors', () => {
|
||||
const floors = getAllGuardianFloors();
|
||||
expect(floors).toContain(210);
|
||||
expect(floors).toContain(250);
|
||||
expect(floors).toContain(300);
|
||||
expect(floors).toContain(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,7 +232,7 @@ describe('PrestigeStore', () => {
|
||||
describe('startPactRitual', () => {
|
||||
it('should start ritual when conditions met', () => {
|
||||
usePrestigeStore.setState({ defeatedGuardians: [10], signedPacts: [], insight: 10000 });
|
||||
const result = usePrestigeStore.getState().startPactRitual(10, 10000);
|
||||
const result = usePrestigeStore.getState().startPactRitual(10, 100000);
|
||||
expect(result.success).toBe(true);
|
||||
expect(usePrestigeStore.getState().pactRitualFloor).toBe(10);
|
||||
});
|
||||
|
||||
+199
-185
@@ -1,249 +1,263 @@
|
||||
// ─── Static Guardian Definitions ──────────────────────────────────────────────
|
||||
// Ordered by floor: base → utility → compound → exotic.
|
||||
// ─── Static Guardian Definitions ─────────────────────────────────────────────────
|
||||
// New 9-tier progression for guardians:
|
||||
//
|
||||
// Floors 10-80: Base elements (Fire, Water, Air, Earth, Light, Dark, Death)
|
||||
// + Utility element (Transference) — 8 guardians total
|
||||
// Floors 90-110: Compound elements (Metal, Sand, Lightning) — 3 guardians
|
||||
// Floors 120-140: Exotic elements (Crystal, Stellar, Void) — 3 guardians
|
||||
// Tier 1: Base Elements (floors 10–80)
|
||||
// Fire, Water, Air, Earth, Light, Dark, Death, Transference
|
||||
// Tier 2: Composite Elements (floors 90–120)
|
||||
// Metal, Sand, Lightning
|
||||
// Tier 3: Composite + Components (floors 130–160)
|
||||
// Tier 4: Exotic Elements (floors 170–200)
|
||||
//
|
||||
// Floor 150+: Procedural combination guardians (see getComboGuardian in guardian-encounters.ts)
|
||||
// Floors 210+ are procedurally generated in guardian-encounters.ts.
|
||||
|
||||
import type { GuardianDef } from '../types';
|
||||
import { resolveMultiUnlockChain } from '../utils/guardian-utils';
|
||||
|
||||
// ─── Shared Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Helper: HP scales exponentially with floor
|
||||
function hp(floor: number): number {
|
||||
const base = 5000;
|
||||
const exponent = 1.1 + (floor / 200);
|
||||
return Math.floor(base * Math.pow(floor / 10, exponent));
|
||||
}
|
||||
|
||||
// Helper: pact cost scales with guardian HP, power, and armor
|
||||
function pactCost(hpVal: number, power: number, armor: number): number {
|
||||
return Math.floor(hpVal * 0.3 + power * 5 + hpVal * armor * 0.5);
|
||||
}
|
||||
|
||||
// ─── Base Elements (Floors 10–70) ────────────────────────────────────────────
|
||||
function mk(
|
||||
floor: number,
|
||||
name: string,
|
||||
element: string[],
|
||||
color: string,
|
||||
armor: number,
|
||||
pactMult: number,
|
||||
boons: GuardianDef['boons'],
|
||||
uniquePerk: string,
|
||||
effects: GuardianDef['effects'],
|
||||
): GuardianDef {
|
||||
const hpVal = hp(floor);
|
||||
const power = Math.floor(hpVal * 0.5);
|
||||
const arm = armor;
|
||||
const pc = pactCost(hpVal, power, arm);
|
||||
const pt = 2 + Math.floor(floor / 10);
|
||||
|
||||
const BASE_GUARDIANS: Record<number, GuardianDef> = {
|
||||
// -- Base elements --
|
||||
10: {
|
||||
name: 'Ignis Prime', element: 'fire', hp: hp(10), pact: 1.5, color: '#FF6B35',
|
||||
armor: 0.10,
|
||||
boons: [
|
||||
return {
|
||||
name,
|
||||
element,
|
||||
hp: hpVal,
|
||||
pact: pactMult,
|
||||
color,
|
||||
armor: arm,
|
||||
boons,
|
||||
pactCost: pc,
|
||||
pactTime: pt,
|
||||
uniquePerk,
|
||||
power,
|
||||
effects,
|
||||
signingCost: { mana: pc, time: pt },
|
||||
unlocksMana: resolveMultiUnlockChain(element),
|
||||
damageMultiplier: 1.0 + floor * 0.01,
|
||||
insightMultiplier: 1.0 + floor * 0.005,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TIER 1: Base Elements (Floors 10–80)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const TIER1: Record<number, GuardianDef> = {
|
||||
10: mk(10, 'Ignis Prime', ['fire'], '#FF6B35', 0.10, 1.5,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 5, desc: '+5% Fire damage' },
|
||||
{ type: 'maxMana', value: 50, desc: '+50 max mana' },
|
||||
],
|
||||
pactCost: pactCost(hp(10), 50, 0.10), pactTime: 2,
|
||||
uniquePerk: 'Fire spells cast 10% faster',
|
||||
power: 50,
|
||||
effects: [{ type: 'burn', value: 0.1 }],
|
||||
signingCost: { mana: 500, time: 2 },
|
||||
unlocksMana: ['fire', 'lightning'],
|
||||
damageMultiplier: 1.1, insightMultiplier: 1.05,
|
||||
},
|
||||
20: {
|
||||
name: 'Aqua Regia', element: 'water', hp: hp(20), pact: 1.75, color: '#4ECDC4',
|
||||
armor: 0.15,
|
||||
boons: [
|
||||
'Fire spells cast 10% faster',
|
||||
[{ type: 'burn', value: 0.1 }],
|
||||
),
|
||||
20: mk(20, 'Aqua Regia', ['water'], '#4ECDC4', 0.15, 1.75,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 5, desc: '+5% Water damage' },
|
||||
{ type: 'manaRegen', value: 0.5, desc: '+0.5 mana regen' },
|
||||
],
|
||||
pactCost: pactCost(hp(20), 150, 0.15), pactTime: 4,
|
||||
uniquePerk: 'Water spells deal +15% damage',
|
||||
power: 150,
|
||||
effects: [{ type: 'armor_pierce', value: 0.15 }],
|
||||
signingCost: { mana: 1000, time: 4 },
|
||||
unlocksMana: ['water', 'sand'],
|
||||
damageMultiplier: 1.2, insightMultiplier: 1.1,
|
||||
},
|
||||
30: {
|
||||
name: 'Ventus Rex', element: 'air', hp: hp(30), pact: 2.0, color: '#00D4FF',
|
||||
armor: 0.18,
|
||||
boons: [
|
||||
'Water spells deal +15% damage',
|
||||
[{ type: 'armor_pierce', value: 0.15 }],
|
||||
),
|
||||
30: mk(30, 'Ventus Rex', ['air'], '#00D4FF', 0.18, 2.0,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 5, desc: '+5% Air damage' },
|
||||
{ type: 'castingSpeed', value: 5, desc: '+5% casting speed' },
|
||||
],
|
||||
pactCost: pactCost(hp(30), 300, 0.18), pactTime: 6,
|
||||
uniquePerk: 'Air spells have 15% crit chance',
|
||||
power: 300,
|
||||
effects: [{ type: 'cast_speed', value: 0.05 }],
|
||||
signingCost: { mana: 2000, time: 6 },
|
||||
unlocksMana: ['air'],
|
||||
damageMultiplier: 1.3, insightMultiplier: 1.15,
|
||||
},
|
||||
40: {
|
||||
name: 'Terra Firma', element: 'earth', hp: hp(40), pact: 2.25, color: '#F4A261',
|
||||
armor: 0.25,
|
||||
boons: [
|
||||
'Air spells have 15% crit chance',
|
||||
[{ type: 'cast_speed', value: 0.05 }],
|
||||
),
|
||||
40: mk(40, 'Terra Firma', ['earth'], '#F4A261', 0.25, 2.25,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 5, desc: '+5% Earth damage' },
|
||||
{ type: 'maxMana', value: 100, desc: '+100 max mana' },
|
||||
],
|
||||
pactCost: pactCost(hp(40), 500, 0.25), pactTime: 8,
|
||||
uniquePerk: 'Earth spells deal +25% damage to guardians',
|
||||
power: 500,
|
||||
effects: [{ type: 'armor_pierce', value: 0.2 }],
|
||||
signingCost: { mana: 4000, time: 8 },
|
||||
unlocksMana: ['earth', 'metal'],
|
||||
damageMultiplier: 1.4, insightMultiplier: 1.2,
|
||||
},
|
||||
50: {
|
||||
name: 'Lux Aeterna', element: 'light', hp: hp(50), pact: 2.5, color: '#FFD700',
|
||||
armor: 0.20,
|
||||
boons: [
|
||||
'Earth spells deal +25% damage to guardians',
|
||||
[{ type: 'armor_pierce', value: 0.2 }],
|
||||
),
|
||||
50: mk(50, 'Lux Aeterna', ['light'], '#FFD700', 0.20, 2.5,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 10, desc: '+10% Light damage' },
|
||||
{ type: 'insightGain', value: 10, desc: '+10% insight gain' },
|
||||
],
|
||||
pactCost: pactCost(hp(50), 800, 0.20), pactTime: 10,
|
||||
uniquePerk: 'Light spells reveal enemy weaknesses (+20% damage)',
|
||||
power: 800,
|
||||
effects: [{ type: 'crit_chance', value: 0.1 }],
|
||||
signingCost: { mana: 8000, time: 10 },
|
||||
unlocksMana: ['light', 'crystal'],
|
||||
damageMultiplier: 1.5, insightMultiplier: 1.3,
|
||||
},
|
||||
60: {
|
||||
name: 'Umbra Mortis', element: 'dark', hp: hp(60), pact: 2.75, color: '#9B59B6',
|
||||
armor: 0.22,
|
||||
boons: [
|
||||
'Light spells reveal enemy weaknesses (+20% damage)',
|
||||
[{ type: 'crit_chance', value: 0.1 }],
|
||||
),
|
||||
60: mk(60, 'Umbra Mortis', ['dark'], '#9B59B6', 0.22, 2.75,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 10, desc: '+10% Dark damage' },
|
||||
{ type: 'critDamage', value: 15, desc: '+15% crit damage' },
|
||||
],
|
||||
pactCost: pactCost(hp(60), 1200, 0.22), pactTime: 12,
|
||||
uniquePerk: 'Dark spells deal +25% damage to armored enemies',
|
||||
power: 1200,
|
||||
effects: [{ type: 'crit_damage', value: 0.15 }],
|
||||
signingCost: { mana: 15000, time: 12 },
|
||||
unlocksMana: ['dark', 'void'],
|
||||
damageMultiplier: 1.6, insightMultiplier: 1.4,
|
||||
},
|
||||
70: {
|
||||
name: 'Mors Ultima', element: 'death', hp: hp(70), pact: 3.0, color: '#778CA3',
|
||||
armor: 0.25,
|
||||
boons: [
|
||||
'Dark spells deal +25% damage to armored enemies',
|
||||
[{ type: 'crit_damage', value: 0.15 }],
|
||||
),
|
||||
70: mk(70, 'Mors Ultima', ['death'], '#778CA3', 0.25, 3.0,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 10, desc: '+10% Death damage' },
|
||||
{ type: 'rawDamage', value: 10, desc: '+10% raw damage' },
|
||||
],
|
||||
pactCost: pactCost(hp(70), 2500, 0.25), pactTime: 14,
|
||||
uniquePerk: 'Death spells execute enemies below 20% HP',
|
||||
power: 2500,
|
||||
effects: [{ type: 'raw_damage', value: 0.1 }],
|
||||
signingCost: { mana: 25000, time: 14 },
|
||||
unlocksMana: ['death'],
|
||||
damageMultiplier: 1.8, insightMultiplier: 1.5,
|
||||
},
|
||||
|
||||
// -- Utility element --
|
||||
80: {
|
||||
name: 'Vinculum Arcana', element: 'transference', hp: hp(80), pact: 3.25, color: '#1ABC9C',
|
||||
armor: 0.20,
|
||||
boons: [
|
||||
'Death spells execute enemies below 20% HP',
|
||||
[{ type: 'raw_damage', value: 0.1 }],
|
||||
),
|
||||
80: mk(80, 'Vinculum Arcana', ['transference'], '#1ABC9C', 0.20, 3.25,
|
||||
[
|
||||
{ type: 'maxMana', value: 150, desc: '+150 max mana' },
|
||||
{ type: 'manaRegen', value: 1.0, desc: '+1.0 mana regen' },
|
||||
],
|
||||
pactCost: pactCost(hp(80), 3500, 0.20), pactTime: 16,
|
||||
uniquePerk: 'Transference spells have 25% reduced cost',
|
||||
power: 3500,
|
||||
effects: [{ type: 'cost_reduction', value: 0.25 }],
|
||||
signingCost: { mana: 35000, time: 16 },
|
||||
unlocksMana: ['transference'],
|
||||
damageMultiplier: 1.85, insightMultiplier: 1.55,
|
||||
},
|
||||
'Transference spells have 25% reduced cost',
|
||||
[{ type: 'cost_reduction', value: 0.25 }],
|
||||
),
|
||||
};
|
||||
|
||||
// -- Compound Elements (Floors 90–110) ───────────────────────────────────────
|
||||
90: {
|
||||
name: '', element: 'metal', hp: hp(90), pact: 3.5, color: '#BDC3C7',
|
||||
armor: 0.30,
|
||||
boons: [
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TIER 2: Composite Elements (Floors 90–120)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const TIER2: Record<number, GuardianDef> = {
|
||||
90: mk(90, '', ['metal'], '#BDC3C7', 0.30, 3.5,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 15, desc: '+15% Metal damage' },
|
||||
{ type: 'maxMana', value: 150, desc: '+150 max mana' },
|
||||
],
|
||||
pactCost: pactCost(hp(90), 6000, 0.30), pactTime: 18,
|
||||
uniquePerk: 'Metal spells pierce 20% armor',
|
||||
power: 6000,
|
||||
effects: [{ type: 'armor_pierce', value: 0.2 }],
|
||||
signingCost: { mana: 60000, time: 18 },
|
||||
unlocksMana: ['metal'],
|
||||
damageMultiplier: 1.9, insightMultiplier: 1.6,
|
||||
},
|
||||
100: {
|
||||
name: '', element: 'sand', hp: hp(100), pact: 3.75, color: '#D4AC0D',
|
||||
armor: 0.25,
|
||||
boons: [
|
||||
'Metal spells pierce 20% armor',
|
||||
[{ type: 'armor_pierce', value: 0.2 }],
|
||||
),
|
||||
100: mk(100, '', ['sand'], '#D4AC0D', 0.25, 3.75,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 15, desc: '+15% Sand damage' },
|
||||
{ type: 'manaRegen', value: 1.5, desc: '+1.5 mana regen' },
|
||||
],
|
||||
pactCost: pactCost(hp(100), 8000, 0.25), pactTime: 20,
|
||||
uniquePerk: 'Sand spells slow enemies by 25%',
|
||||
power: 8000,
|
||||
effects: [{ type: 'slow', value: 0.25 }],
|
||||
signingCost: { mana: 80000, time: 20 },
|
||||
unlocksMana: ['sand'],
|
||||
damageMultiplier: 2.0, insightMultiplier: 1.7,
|
||||
},
|
||||
110: {
|
||||
name: '', element: 'lightning', hp: hp(110), pact: 4.0, color: '#FFEB3B',
|
||||
armor: 0.22,
|
||||
boons: [
|
||||
'Sand spells slow enemies by 25%',
|
||||
[{ type: 'slow', value: 0.25 }],
|
||||
),
|
||||
110: mk(110, '', ['lightning'], '#FFEB3B', 0.22, 4.0,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 15, desc: '+15% Lightning damage' },
|
||||
{ type: 'castingSpeed', value: 15, desc: '+15% casting speed' },
|
||||
],
|
||||
pactCost: pactCost(hp(110), 10000, 0.22), pactTime: 22,
|
||||
uniquePerk: 'Lightning spells chain to 2 additional targets',
|
||||
power: 10000,
|
||||
effects: [{ type: 'chain', value: 2 }],
|
||||
signingCost: { mana: 100000, time: 22 },
|
||||
unlocksMana: ['lightning'],
|
||||
damageMultiplier: 2.1, insightMultiplier: 1.8,
|
||||
},
|
||||
'Lightning spells chain to 2 additional targets',
|
||||
[{ type: 'chain', value: 2 }],
|
||||
),
|
||||
};
|
||||
|
||||
// -- Exotic Elements (Floors 120–140) ────────────────────────────────────────
|
||||
120: {
|
||||
name: '', element: 'crystal', hp: hp(120), pact: 4.5, color: '#85C1E9',
|
||||
armor: 0.35,
|
||||
boons: [
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TIER 3: Composite + Their Components (Floors 130–160)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const TIER3: Record<number, GuardianDef> = {
|
||||
130: mk(130, '', ['metal', 'fire', 'earth'], '#D4A574', 0.35, 4.5,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 20, desc: '+20% Metal damage' },
|
||||
{ type: 'elementalDamage', value: 10, desc: '+10% Fire damage' },
|
||||
{ type: 'elementalDamage', value: 10, desc: '+10% Earth damage' },
|
||||
],
|
||||
'Tri-aspect: Metal, Fire, and Earth spells gain +10% effectiveness',
|
||||
[{ type: 'armor_pierce', value: 0.25 }, { type: 'burn', value: 0.1 }],
|
||||
),
|
||||
140: mk(140, '', ['sand', 'earth', 'water'], '#C9B896', 0.30, 4.75,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 20, desc: '+20% Sand damage' },
|
||||
{ type: 'elementalDamage', value: 10, desc: '+10% Earth damage' },
|
||||
{ type: 'elementalDamage', value: 10, desc: '+10% Water damage' },
|
||||
],
|
||||
'Tri-aspect: Sand, Earth, and Water spells gain +10% effectiveness',
|
||||
[{ type: 'slow', value: 0.3 }, { type: 'armor_pierce', value: 0.15 }],
|
||||
),
|
||||
150: mk(150, '', ['lightning', 'fire', 'air'], '#FFE066', 0.28, 5.0,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 20, desc: '+20% Lightning damage' },
|
||||
{ type: 'elementalDamage', value: 10, desc: '+10% Fire damage' },
|
||||
{ type: 'elementalDamage', value: 10, desc: '+10% Air damage' },
|
||||
],
|
||||
'Tri-aspect: Lightning, Fire, and Air spells gain +10% effectiveness',
|
||||
[{ type: 'chain', value: 2 }, { type: 'cast_speed', value: 0.1 }],
|
||||
),
|
||||
160: mk(160, '', ['metal', 'lightning', 'fire', 'earth', 'air'], '#E8C872', 0.35, 5.25,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 15, desc: '+15% Metal damage' },
|
||||
{ type: 'elementalDamage', value: 15, desc: '+15% Lightning damage' },
|
||||
{ type: 'rawDamage', value: 10, desc: '+10% raw damage' },
|
||||
],
|
||||
'Fused aspects: Lightning spells gain +20% armor pierce; Metal spells chain once',
|
||||
[{ type: 'armor_pierce', value: 0.3 }, { type: 'chain', value: 1 }],
|
||||
),
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TIER 4: Exotic Elements (Floors 170–200)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const TIER4: Record<number, GuardianDef> = {
|
||||
170: mk(170, '', ['crystal'], '#85C1E9', 0.35, 5.5,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 20, desc: '+20% Crystal damage' },
|
||||
{ type: 'maxMana', value: 300, desc: '+300 max mana' },
|
||||
{ type: 'manaRegen', value: 2, desc: '+2 mana regen' },
|
||||
],
|
||||
pactCost: pactCost(hp(120), 15000, 0.35), pactTime: 26,
|
||||
uniquePerk: 'Crystal spells reflect 15% damage back to attackers',
|
||||
power: 15000,
|
||||
effects: [{ type: 'reflect', value: 0.15 }],
|
||||
signingCost: { mana: 150000, time: 26 },
|
||||
unlocksMana: ['crystal'],
|
||||
damageMultiplier: 2.3, insightMultiplier: 1.9,
|
||||
},
|
||||
130: {
|
||||
name: '', element: 'stellar', hp: hp(130), pact: 5.0, color: '#F0E68C',
|
||||
armor: 0.30,
|
||||
boons: [
|
||||
'Crystal spells reflect 15% damage back to attackers',
|
||||
[{ type: 'reflect', value: 0.15 }],
|
||||
),
|
||||
180: mk(180, '', ['stellar'], '#F0E68C', 0.30, 6.0,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 25, desc: '+25% Stellar damage' },
|
||||
{ type: 'insightGain', value: 20, desc: '+20% insight gain' },
|
||||
],
|
||||
pactCost: pactCost(hp(130), 20000, 0.30), pactTime: 30,
|
||||
uniquePerk: 'Stellar spells deal +30% damage at night',
|
||||
power: 20000,
|
||||
effects: [{ type: 'night_bonus', value: 0.3 }],
|
||||
signingCost: { mana: 200000, time: 30 },
|
||||
unlocksMana: ['stellar'],
|
||||
damageMultiplier: 2.5, insightMultiplier: 2.0,
|
||||
},
|
||||
140: {
|
||||
name: '', element: 'void', hp: hp(140), pact: 5.5, color: '#4A235A',
|
||||
armor: 0.35,
|
||||
boons: [
|
||||
'Stellar spells deal +30% damage at night',
|
||||
[{ type: 'night_bonus', value: 0.3 }],
|
||||
),
|
||||
190: mk(190, '', ['void'], '#4A235A', 0.35, 6.5,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 25, desc: '+25% Void damage' },
|
||||
{ type: 'rawDamage', value: 15, desc: '+15% raw damage' },
|
||||
{ type: 'maxMana', value: 400, desc: '+400 max mana' },
|
||||
],
|
||||
pactCost: pactCost(hp(140), 30000, 0.35), pactTime: 34,
|
||||
uniquePerk: 'Void spells ignore 40% of all resistances',
|
||||
power: 30000,
|
||||
effects: [{ type: 'resist_ignore', value: 0.4 }],
|
||||
signingCost: { mana: 300000, time: 34 },
|
||||
unlocksMana: ['void'],
|
||||
damageMultiplier: 2.8, insightMultiplier: 2.2,
|
||||
},
|
||||
'Void spells ignore 40% of all resistances',
|
||||
[{ type: 'resist_ignore', value: 0.4 }],
|
||||
),
|
||||
200: mk(200, '', ['crystal', 'stellar', 'void'], '#B39DDB', 0.40, 7.0,
|
||||
[
|
||||
{ type: 'elementalDamage', value: 15, desc: '+15% Crystal damage' },
|
||||
{ type: 'elementalDamage', value: 15, desc: '+15% Stellar damage' },
|
||||
{ type: 'elementalDamage', value: 15, desc: '+15% Void damage' },
|
||||
],
|
||||
'Exotic convergence: All exotic spells gain +15% effectiveness',
|
||||
[{ type: 'reflect', value: 0.1 }, { type: 'resist_ignore', value: 0.1 }],
|
||||
),
|
||||
};
|
||||
|
||||
export { BASE_GUARDIANS };
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// COMBINED STATIC GUARDIANS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const STATIC_GUARDIANS: Record<number, GuardianDef> = {
|
||||
...TIER1,
|
||||
...TIER2,
|
||||
...TIER3,
|
||||
...TIER4,
|
||||
};
|
||||
|
||||
export { STATIC_GUARDIANS, TIER1, TIER2, TIER3, TIER4 };
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
// ─── Guardian Encounters ───────────────────────────────────────────────────────
|
||||
// ─── Guardian Encounters ─────────────────────────────────────────────────────────
|
||||
// Procedural guardian generation and unified lookup.
|
||||
//
|
||||
// Guardian progression:
|
||||
// Floors 10-80: Base + utility elements (static, in guardian-data.ts)
|
||||
// Floors 90-110: Compound elements (static, in guardian-data.ts)
|
||||
// Floors 120-140: Exotic elements (static, in guardian-data.ts)
|
||||
// Floor 150+: Procedural combo guardians (this file)
|
||||
// Guardian progression (9 tiers):
|
||||
// Tier 1: Base Elements (static, floors 10–80, guardian-data.ts)
|
||||
// Tier 2: Composite Elements (static, floors 90–120, guardian-data.ts)
|
||||
// Tier 3: Composite+Components (static, floors 130–160, guardian-data.ts)
|
||||
// Tier 4: Exotic Elements (static, floors 170–200, guardian-data.ts)
|
||||
// Tier 5: Dual Element Pairs (dynamic, floors 210–240, this file)
|
||||
// Tier 6: Dual Comp+Components (dynamic, floors 250–290, this file)
|
||||
// Tier 7: Exotic+Components (dynamic, floors 300–340, this file)
|
||||
// Tier 8: Exotic+Comp+Components (dynamic, floors 350–390, this file)
|
||||
// Tier 9: Full Fusion (dynamic, floors 400+, this file)
|
||||
//
|
||||
// All lookups go through getGuardianForFloor() which merges static + procedural.
|
||||
|
||||
import type { GuardianDef } from '../types';
|
||||
import { BASE_GUARDIANS } from './guardian-data';
|
||||
import type { GuardianDef, GuardianBoon } from '../types';
|
||||
import { STATIC_GUARDIANS } from './guardian-data';
|
||||
import { resolveMultiUnlockChain } from '../utils/guardian-utils';
|
||||
|
||||
// ─── Name Generation ──────────────────────────────────────────────────────────
|
||||
// ─── Name Generation ────────────────────────────────────────────────────────────
|
||||
|
||||
const GUARDIAN_PREFIXES: Record<string, string[]> = {
|
||||
fire: ['Ignis', 'Pyra', 'Sol', 'Vulcan', 'Ember'],
|
||||
@@ -36,14 +42,7 @@ const GUARDIAN_TITLES: string[] = [
|
||||
'Guardian', 'Sentinel', 'Champion', 'Overlord', 'Archon',
|
||||
];
|
||||
|
||||
export function generateGuardianName(element: string, floor: number = 0): string {
|
||||
const prefixes = GUARDIAN_PREFIXES[element] || ['Unknown'];
|
||||
const prefix = prefixes[floor % prefixes.length];
|
||||
const title = GUARDIAN_TITLES[Math.floor(floor / 10) % GUARDIAN_TITLES.length];
|
||||
return `${prefix} the ${title}`;
|
||||
}
|
||||
|
||||
export function generateComboGuardianName(elements: string[], floor: number = 0): string {
|
||||
export function generateGuardianName(elements: string[], floor: number = 0): string {
|
||||
const parts = elements.map((el, i) => {
|
||||
const prefixes = GUARDIAN_PREFIXES[el] || ['Unknown'];
|
||||
return prefixes[(floor + i) % prefixes.length];
|
||||
@@ -52,7 +51,12 @@ export function generateComboGuardianName(elements: string[], floor: number = 0)
|
||||
return `${parts.join('-')} the ${title}`;
|
||||
}
|
||||
|
||||
// ─── Guardian HP Scaling ──────────────────────────────────────────────────────
|
||||
/** @deprecated Use generateGuardianName(elements[], floor) instead. Kept for backward compat. */
|
||||
export function generateGuardianNameSingle(element: string, floor: number = 0): string {
|
||||
return generateGuardianName([element], floor);
|
||||
}
|
||||
|
||||
// ─── Guardian HP Scaling ───────────────────────────────────────────────────────
|
||||
|
||||
export function getGuardianHP(floor: number): number {
|
||||
const base = 5000;
|
||||
@@ -60,96 +64,315 @@ export function getGuardianHP(floor: number): number {
|
||||
return Math.floor(base * Math.pow(floor / 10, exponent));
|
||||
}
|
||||
|
||||
// ─── Combination Guardians (Floor 150+) ───────────────────────────────────────
|
||||
// Procedural guardians that get stronger over time. Each combo pairs two
|
||||
// base/utility elements and scales stats based on floor number.
|
||||
// ─── Tier Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
const COMBO_PAIRS: [string, string][] = [
|
||||
['fire', 'water'], // Steam
|
||||
['fire', 'air'], // Smoke
|
||||
['water', 'earth'], // Mud
|
||||
['light', 'dark'], // Twilight
|
||||
['death', 'light'], // Undeath
|
||||
['fire', 'death'], // Hellfire
|
||||
['water', 'dark'], // Abyssal
|
||||
['air', 'light'], // Radiant wind
|
||||
['earth', 'death'], // Fossil
|
||||
const BASE_ELEMENTS = ['fire', 'water', 'air', 'earth', 'light', 'dark', 'death'];
|
||||
const COMPOSITE_ELEMENTS = ['metal', 'sand', 'lightning'];
|
||||
const EXOTIC_ELEMENTS = ['crystal', 'stellar', 'void'];
|
||||
|
||||
/** Generate boon elements (max 3) */
|
||||
function makeBoons(elements: string[], floor: number): GuardianBoon[] {
|
||||
return elements.slice(0, 3).map((el) => ({
|
||||
type: 'elementalDamage' as const,
|
||||
value: 10 + Math.floor(floor / 20) * 5,
|
||||
desc: `+${10 + Math.floor(floor / 20) * 5}% ${el} damage`,
|
||||
}));
|
||||
}
|
||||
|
||||
function getTier(floor: number): number {
|
||||
if (floor >= 400) return 9;
|
||||
if (floor >= 350) return 8;
|
||||
if (floor >= 300) return 7;
|
||||
if (floor >= 250) return 6;
|
||||
if (floor >= 210) return 5;
|
||||
return 0; // Static tiers
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TIER 5: Dual Element Pairs (Floors 210–240)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const DUAL_PAIRS: [string, string][] = [
|
||||
['fire', 'water'],
|
||||
['fire', 'air'],
|
||||
['water', 'earth'],
|
||||
['light', 'dark'],
|
||||
['death', 'light'],
|
||||
['fire', 'death'],
|
||||
['water', 'dark'],
|
||||
['air', 'light'],
|
||||
['earth', 'death'],
|
||||
];
|
||||
|
||||
export function getComboGuardian(floor: number): GuardianDef {
|
||||
const comboIndex = Math.floor((floor - 150) / 10) % COMBO_PAIRS.length;
|
||||
const [el1, el2] = COMBO_PAIRS[comboIndex];
|
||||
const hp = getGuardianHP(floor);
|
||||
const armor = Math.min(0.5, 0.25 + (floor - 150) * 0.002);
|
||||
function getTier5Guardian(floor: number): GuardianDef {
|
||||
const idx = Math.floor((floor - 210) / 10) % DUAL_PAIRS.length;
|
||||
const [el1, el2] = DUAL_PAIRS[idx];
|
||||
const elements = [el1, el2];
|
||||
const hpVal = getGuardianHP(floor);
|
||||
const armor = Math.min(0.5, 0.30 + (floor - 210) * 0.003);
|
||||
|
||||
return {
|
||||
name: '',
|
||||
element: `${el1}+${el2}`,
|
||||
hp,
|
||||
pact: 6.0 + (floor - 150) * 0.05,
|
||||
color: '#E8D5F5',
|
||||
element: elements,
|
||||
hp: hpVal,
|
||||
pact: 7.5 + (floor - 210) * 0.05,
|
||||
color: blendColors(el1, el2),
|
||||
armor,
|
||||
boons: [
|
||||
{ type: 'elementalDamage', value: 10, desc: `+10% ${el1} damage` },
|
||||
{ type: 'elementalDamage', value: 10, desc: `+10% ${el2} damage` },
|
||||
],
|
||||
pactCost: Math.floor(hp * 0.3 + Math.floor(hp * 0.5) * 5 + hp * armor * 0.5),
|
||||
pactTime: 20 + Math.floor((floor - 150) / 10),
|
||||
boons: makeBoons(elements, floor),
|
||||
pactCost: Math.floor(hpVal * 0.3 + hpVal * armor * 0.5),
|
||||
pactTime: 20 + Math.floor((floor - 210) / 10),
|
||||
uniquePerk: `Dual-aspect: ${el1} and ${el2} spells gain +20% effectiveness`,
|
||||
power: Math.floor(hp * 0.5),
|
||||
power: Math.floor(hpVal * 0.5),
|
||||
effects: [
|
||||
{ type: `${el1}_boost`, value: 0.2 },
|
||||
{ type: `${el2}_boost`, value: 0.2 },
|
||||
{ type: `${el1}_boost`, value: 0.15 },
|
||||
{ type: `${el2}_boost`, value: 0.15 },
|
||||
],
|
||||
signingCost: { mana: Math.floor(hp * 0.3 + Math.floor(hp * 0.5) * 5 + hp * armor * 0.5), time: 20 + Math.floor((floor - 150) / 10) },
|
||||
unlocksMana: [el1, el2],
|
||||
damageMultiplier: 3.0 + (floor - 150) * 0.02,
|
||||
insightMultiplier: 2.5 + (floor - 150) * 0.01,
|
||||
signingCost: {
|
||||
mana: Math.floor(hpVal * 0.3),
|
||||
time: 20 + Math.floor((floor - 210) / 10),
|
||||
},
|
||||
unlocksMana: resolveMultiUnlockChain(elements),
|
||||
damageMultiplier: 3.5 + (floor - 210) * 0.02,
|
||||
insightMultiplier: 3.0 + (floor - 210) * 0.01,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Procedural Guardian Lookup (Floor 150+) ──────────────────────────────────
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TIER 6: Dual Composite + Components (Floors 250–290)
|
||||
// Pairs of composites with all their base components.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function getExtendedGuardian(floor: number): GuardianDef | null {
|
||||
if (floor >= 150 && floor % 10 === 0) {
|
||||
const g = getComboGuardian(floor);
|
||||
if (!g.name) {
|
||||
const elements = g.element.split('+');
|
||||
g.name = generateComboGuardianName(elements, floor);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
return null;
|
||||
const DUAL_COMP_PAIRS: [string, string][] = [
|
||||
['metal', 'sand'],
|
||||
['metal', 'lightning'],
|
||||
['sand', 'lightning'],
|
||||
];
|
||||
|
||||
function getTier6Guardian(floor: number): GuardianDef {
|
||||
const idx = Math.floor((floor - 250) / 10) % DUAL_COMP_PAIRS.length;
|
||||
const [comp1, comp2] = DUAL_COMP_PAIRS[idx];
|
||||
const elements = resolveMultiUnlockChain([comp1, comp2]);
|
||||
const hpVal = getGuardianHP(floor);
|
||||
const armor = Math.min(0.55, 0.35 + (floor - 250) * 0.003);
|
||||
|
||||
return {
|
||||
name: '',
|
||||
element: elements,
|
||||
hp: hpVal,
|
||||
pact: 9.0 + (floor - 250) * 0.05,
|
||||
color: blendColors(comp1, comp2),
|
||||
armor,
|
||||
boons: makeBoons([comp1, comp2], floor),
|
||||
pactCost: Math.floor(hpVal * 0.3 + hpVal * armor * 0.5),
|
||||
pactTime: 24 + Math.floor((floor - 250) / 10),
|
||||
uniquePerk: `Fusion twin-aspect: ${comp1} and ${comp2} spells gain +25% effectiveness`,
|
||||
power: Math.floor(hpVal * 0.55),
|
||||
effects: [
|
||||
{ type: 'armor_pierce', value: 0.2 },
|
||||
{ type: 'chain', value: 1 },
|
||||
],
|
||||
signingCost: {
|
||||
mana: Math.floor(hpVal * 0.35),
|
||||
time: 24 + Math.floor((floor - 250) / 10),
|
||||
},
|
||||
unlocksMana: resolveMultiUnlockChain(elements),
|
||||
damageMultiplier: 4.0 + (floor - 250) * 0.02,
|
||||
insightMultiplier: 3.5 + (floor - 250) * 0.01,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Unified Guardian System ─────────────────────────────────────────────────
|
||||
// Merges static guardians (floors 10–140) with procedural combo guardians (150+).
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TIER 7: Exotic + Components (Floors 300–340)
|
||||
// Each exotic element paired with all its component base elements.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getTier7Guardian(floor: number): GuardianDef {
|
||||
const exoticIdx = Math.floor((floor - 300) / 10) % EXOTIC_ELEMENTS.length;
|
||||
const exoticEl = EXOTIC_ELEMENTS[exoticIdx];
|
||||
const chain = resolveMultiUnlockChain([exoticEl]);
|
||||
const hpVal = getGuardianHP(floor);
|
||||
const armor = Math.min(0.6, 0.40 + (floor - 300) * 0.003);
|
||||
|
||||
return {
|
||||
name: '',
|
||||
element: chain,
|
||||
hp: hpVal,
|
||||
pact: 10.5 + (floor - 300) * 0.05,
|
||||
color: '#B8A9C9',
|
||||
armor,
|
||||
boons: [makeBoons([exoticEl], floor)[0]],
|
||||
pactCost: Math.floor(hpVal * 0.35 + hpVal * armor * 0.5),
|
||||
pactTime: 28 + Math.floor((floor - 300) / 10),
|
||||
uniquePerk: `Exotic resonance: ${exoticEl} spells gain +30% effectiveness`,
|
||||
power: Math.floor(hpVal * 0.6),
|
||||
effects: [
|
||||
{ type: 'resist_ignore', value: 0.2 },
|
||||
{ type: 'reflect', value: 0.1 },
|
||||
],
|
||||
signingCost: {
|
||||
mana: Math.floor(hpVal * 0.35),
|
||||
time: 28 + Math.floor((floor - 300) / 10),
|
||||
},
|
||||
unlocksMana: chain,
|
||||
damageMultiplier: 4.5 + (floor - 300) * 0.02,
|
||||
insightMultiplier: 4.0 + (floor - 300) * 0.01,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TIER 8: Exotic + Composite + Components (Floors 350–390)
|
||||
// One exotic + one composite + all base elements.
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getTier8Guardian(floor: number): GuardianDef {
|
||||
const exoticIdx = Math.floor((floor - 350) / 10) % EXOTIC_ELEMENTS.length;
|
||||
const compIdx = Math.floor((floor - 350) / 10) % COMPOSITE_ELEMENTS.length;
|
||||
const exoticEl = EXOTIC_ELEMENTS[exoticIdx];
|
||||
const compEl = COMPOSITE_ELEMENTS[compIdx];
|
||||
const elements = resolveMultiUnlockChain([exoticEl, compEl]);
|
||||
const hpVal = getGuardianHP(floor);
|
||||
const armor = Math.min(0.65, 0.45 + (floor - 350) * 0.003);
|
||||
|
||||
return {
|
||||
name: '',
|
||||
element: elements,
|
||||
hp: hpVal,
|
||||
pact: 12.0 + (floor - 350) * 0.05,
|
||||
color: '#9B72AA',
|
||||
armor,
|
||||
boons: makeBoons([exoticEl, compEl], floor),
|
||||
pactCost: Math.floor(hpVal * 0.4 + hpVal * armor * 0.5),
|
||||
pactTime: 32 + Math.floor((floor - 350) / 10),
|
||||
uniquePerk: `Primordial fusion: ${exoticEl} and ${compEl} spells gain +25% effectiveness`,
|
||||
power: Math.floor(hpVal * 0.65),
|
||||
effects: [
|
||||
{ type: 'resist_ignore', value: 0.25 },
|
||||
{ type: 'armor_pierce', value: 0.2 },
|
||||
{ type: 'chain', value: 1 },
|
||||
],
|
||||
signingCost: {
|
||||
mana: Math.floor(hpVal * 0.4),
|
||||
time: 32 + Math.floor((floor - 350) / 10),
|
||||
},
|
||||
unlocksMana: elements,
|
||||
damageMultiplier: 5.0 + (floor - 350) * 0.02,
|
||||
insightMultiplier: 4.5 + (floor - 350) * 0.01,
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TIER 9: Full Fusion — 1 Exotic + 2 Composite + All Components (Floors 400+)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getTier9Guardian(floor: number): GuardianDef {
|
||||
const exoticIdx = (Math.floor((floor - 400) / 10)) % EXOTIC_ELEMENTS.length;
|
||||
const exoticEl = EXOTIC_ELEMENTS[exoticIdx];
|
||||
// Pick 2 different composites
|
||||
const comp1 = COMPOSITE_ELEMENTS[Math.floor((floor - 400) / 10) % COMPOSITE_ELEMENTS.length];
|
||||
const comp2 = COMPOSITE_ELEMENTS[(Math.floor((floor - 400) / 10) + 1) % COMPOSITE_ELEMENTS.length];
|
||||
const elements = resolveMultiUnlockChain([exoticEl, comp1, comp2]);
|
||||
const hpVal = getGuardianHP(floor);
|
||||
const armor = Math.min(0.7, 0.50 + (floor - 400) * 0.002);
|
||||
|
||||
return {
|
||||
name: '',
|
||||
element: elements,
|
||||
hp: hpVal,
|
||||
pact: 14.0 + (floor - 400) * 0.05,
|
||||
color: '#7B5E9A',
|
||||
armor,
|
||||
boons: makeBoons([exoticEl, comp1, comp2], floor),
|
||||
pactCost: Math.floor(hpVal * 0.45 + hpVal * armor * 0.5),
|
||||
pactTime: 36 + Math.floor((floor - 400) / 10),
|
||||
uniquePerk: `Cosmic convergence: All exotic, composite, and base spells gain +15% effectiveness`,
|
||||
power: Math.floor(hpVal * 0.7),
|
||||
effects: [
|
||||
{ type: 'resist_ignore', value: 0.3 },
|
||||
{ type: 'armor_pierce', value: 0.25 },
|
||||
{ type: 'chain', value: 2 },
|
||||
{ type: 'reflect', value: 0.1 },
|
||||
],
|
||||
signingCost: {
|
||||
mana: Math.floor(hpVal * 0.45),
|
||||
time: 36 + Math.floor((floor - 400) / 10),
|
||||
},
|
||||
unlocksMana: elements,
|
||||
damageMultiplier: 5.5 + (floor - 400) * 0.02,
|
||||
insightMultiplier: 5.0 + (floor - 400) * 0.01,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Blending Colors for Dual/Multi-Element Guardians ──────────────────────────
|
||||
|
||||
const ELEMENT_COLORS: Record<string, string> = {
|
||||
fire: '#FF6B35', water: '#4ECDC4', air: '#00D4FF', earth: '#F4A261',
|
||||
light: '#FFD700', dark: '#9B59B6', death: '#778CA3', transference: '#1ABC9C',
|
||||
metal: '#BDC3C7', sand: '#D4AC0D', lightning: '#FFEB3B',
|
||||
crystal: '#85C1E9', stellar: '#F0E68C', void: '#4A235A',
|
||||
};
|
||||
|
||||
function blendColors(el1: string, el2: string): string {
|
||||
const c1 = ELEMENT_COLORS[el1] || '#888888';
|
||||
const c2 = ELEMENT_COLORS[el2] || '#888888';
|
||||
try {
|
||||
const r = Math.floor((parseInt(c1.slice(1, 3), 16) + parseInt(c2.slice(1, 3), 16)) / 2);
|
||||
const g = Math.floor((parseInt(c1.slice(3, 5), 16) + parseInt(c2.slice(3, 5), 16)) / 2);
|
||||
const b = Math.floor((parseInt(c1.slice(5, 7), 16) + parseInt(c2.slice(5, 7), 16)) / 2);
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||
} catch {
|
||||
return '#888888';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Procedural Guardian Generator ─────────────────────────────────────────────
|
||||
|
||||
function getProceduralGuardian(floor: number): GuardianDef | null {
|
||||
if (floor < 210 || floor % 10 !== 0) return null;
|
||||
|
||||
const tier = getTier(floor);
|
||||
let g: GuardianDef;
|
||||
|
||||
switch (tier) {
|
||||
case 5: g = getTier5Guardian(floor); break;
|
||||
case 6: g = getTier6Guardian(floor); break;
|
||||
case 7: g = getTier7Guardian(floor); break;
|
||||
case 8: g = getTier8Guardian(floor); break;
|
||||
case 9: g = getTier9Guardian(floor); break;
|
||||
default: return null;
|
||||
}
|
||||
|
||||
if (!g.name) {
|
||||
g.name = generateGuardianName(g.element, floor);
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
// ─── Unified Guardian System ────────────────────────────────────────────────────
|
||||
|
||||
/** Get the guardian for any floor. Returns null if no guardian at that floor. */
|
||||
export function getGuardianForFloor(floor: number): GuardianDef | null {
|
||||
if (BASE_GUARDIANS[floor]) {
|
||||
const g = { ...BASE_GUARDIANS[floor] };
|
||||
if (STATIC_GUARDIANS[floor]) {
|
||||
const g = { ...STATIC_GUARDIANS[floor] };
|
||||
if (!g.name) g.name = generateGuardianName(g.element, floor);
|
||||
return g;
|
||||
}
|
||||
return getExtendedGuardian(floor);
|
||||
return getProceduralGuardian(floor);
|
||||
}
|
||||
|
||||
/** All guardian floors — merged from static + extended. */
|
||||
/** All guardian floors — dynamically computed. */
|
||||
export function getAllGuardianFloors(): number[] {
|
||||
const staticFloors = Object.keys(BASE_GUARDIANS).map(Number);
|
||||
const comboFloors = Array.from({ length: 10 }, (_, i) => 150 + i * 10);
|
||||
const all = new Set([...staticFloors, ...comboFloors]);
|
||||
// Static floors from guardian-data.ts
|
||||
const staticFloors = Object.keys(STATIC_GUARDIANS).map(Number);
|
||||
// Procedural floors: every 10th floor from 210 to 450
|
||||
const proceduralFloors: number[] = [];
|
||||
for (let f = 210; f <= 450; f += 10) {
|
||||
proceduralFloors.push(f);
|
||||
}
|
||||
const all = new Set([...staticFloors, ...proceduralFloors]);
|
||||
return Array.from(all).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/** All guardian floors including procedural — kept for backwards compatibility. */
|
||||
export const ALL_GUARDIAN_FLOORS: number[] = [
|
||||
...Object.keys(BASE_GUARDIANS).map(Number),
|
||||
...Array.from({ length: 10 }, (_, i) => 150 + i * 10),
|
||||
].sort((a, b) => a - b);
|
||||
|
||||
/** Check if a floor is a guardian floor (every 10th floor). */
|
||||
/** Check if a floor is a guardian floor (every 10th floor with a defined guardian). */
|
||||
export function isGuardianFloor(floor: number): boolean {
|
||||
return floor % 10 === 0;
|
||||
return floor % 10 === 0 && floor >= 10;
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface GuardianBoon {
|
||||
|
||||
export interface GuardianDef {
|
||||
name: string;
|
||||
element: string;
|
||||
element: string[];
|
||||
hp: number;
|
||||
pact: number; // Pact multiplier when signed
|
||||
color: string;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { DisciplineBonuses } from './mana-utils';
|
||||
import { SPELLS_DEF, ELEMENT_OPPOSITES, INCURSION_START_DAY, MAX_DAY } from '../constants';
|
||||
import { ENCHANTMENT_EFFECTS } from '../data/enchantment-effects';
|
||||
import { getGuardianForFloor } from '../data/guardian-encounters';
|
||||
import { BASE_GUARDIANS } from '../data/guardian-data';
|
||||
import { STATIC_GUARDIANS as BASE_GUARDIANS } from '../data/guardian-data';
|
||||
|
||||
// ─── Damage Calculation Params ──────────────────────────────────────────────
|
||||
|
||||
@@ -160,7 +160,7 @@ export function calcDamage(
|
||||
const elemMasteryBonus = 1;
|
||||
|
||||
// Guardian bane bonus
|
||||
const isGuardianFloor = floorElem && Object.values(BASE_GUARDIANS).some(g => g.element === floorElem);
|
||||
const isGuardianFloor = floorElem && Object.values(BASE_GUARDIANS).some(g => g.element.includes(floorElem));
|
||||
const guardianBonus = 1;
|
||||
|
||||
// Get boon bonuses from pacts
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// ─── Guardian Utilities ─────────────────────────────────────────────────────────
|
||||
// Helper functions for guardian element resolution and name generation.
|
||||
|
||||
import { ELEMENTS } from '../constants/elements';
|
||||
|
||||
// ─── Element Chain Resolution ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the full component chain for an element.
|
||||
* Walks the recipe tree from ELEMENTS to collect all base elements + the element itself.
|
||||
*
|
||||
* Examples:
|
||||
* resolveUnlockChain('fire') → ['fire']
|
||||
* resolveUnlockChain('metal') → ['fire', 'earth', 'metal']
|
||||
* resolveUnlockChain('crystal') → ['earth', 'water', 'light', 'sand', 'crystal']
|
||||
* resolveUnlockChain('stellar') → ['fire', 'light', 'stellar']
|
||||
* resolveUnlockChain('void') → ['dark', 'death', 'void']
|
||||
*/
|
||||
export function resolveUnlockChain(element: string): string[] {
|
||||
const result: string[] = [];
|
||||
const visited = new Set<string>();
|
||||
const queue: string[] = [element];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
if (visited.has(current)) continue;
|
||||
visited.add(current);
|
||||
|
||||
const def = ELEMENTS[current];
|
||||
if (!def) continue;
|
||||
|
||||
if (def.recipe) {
|
||||
for (const component of def.recipe) {
|
||||
if (!visited.has(component)) {
|
||||
queue.push(component);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Base, utility, or raw element — no recipe, it's a leaf
|
||||
result.push(current);
|
||||
}
|
||||
}
|
||||
|
||||
// Always include the element itself
|
||||
if (!result.includes(element)) {
|
||||
result.push(element);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve unlock chains for multiple elements (union, deduped).
|
||||
* Used when a guardian has multiple elements.
|
||||
*
|
||||
* Example:
|
||||
* resolveMultiUnlockChain(['fire', 'earth']) → ['fire', 'earth']
|
||||
* resolveMultiUnlockChain(['metal', 'sand']) → ['fire', 'earth', 'water', 'metal', 'sand']
|
||||
*/
|
||||
export function resolveMultiUnlockChain(elements: string[]): string[] {
|
||||
const all = new Set<string>();
|
||||
for (const el of elements) {
|
||||
for (const resolved of resolveUnlockChain(el)) {
|
||||
all.add(resolved);
|
||||
}
|
||||
}
|
||||
return Array.from(all);
|
||||
}
|
||||
|
||||
// ─── Dynamic Guardian Floor Computation ────────────────────────────────────────
|
||||
|
||||
const TIER_CONFIG = [
|
||||
// [startFloor, endFloor, tiersPerFloor, description]
|
||||
{ start: 10, end: 80, spacing: 10 }, // Base + utility: floors 10-80
|
||||
{ start: 90, end: 120, spacing: 10 }, // Composite: floors 90-120
|
||||
{ start: 130, end: 160, spacing: 10 }, // Composite + Components
|
||||
{ start: 170, end: 200, spacing: 10 }, // Exotic
|
||||
{ start: 210, end: 240, spacing: 10 }, // Dual Element
|
||||
{ start: 250, end: 290, spacing: 10 }, // Dual Composite + Components
|
||||
{ start: 300, end: 340, spacing: 10 }, // Exotic + Components
|
||||
{ start: 350, end: 390, spacing: 10 }, // Exotic + Composite + Components
|
||||
{ start: 400, end: 450, spacing: 10 }, // 1 Exotic + 2 Composite + All Components
|
||||
] as const;
|
||||
|
||||
/** Get all guardian floors from a given start, dynamically computed. */
|
||||
export function computeGuardianFloors(maxFloor: number = 450): number[] {
|
||||
const floors: number[] = [];
|
||||
for (const tier of TIER_CONFIG) {
|
||||
for (let f = tier.start; f <= Math.min(tier.end, maxFloor); f += tier.spacing) {
|
||||
floors.push(f);
|
||||
}
|
||||
}
|
||||
return floors.sort((a, b) => a - b);
|
||||
}
|
||||
@@ -109,7 +109,7 @@ export function generateFloorState(floor: number): FloorState {
|
||||
armor: guardian.armor || 0,
|
||||
dodgeChance: 0,
|
||||
barrier: 0,
|
||||
element: guardian.element,
|
||||
element: guardian.element.join('+'),
|
||||
}],
|
||||
};
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ export function generateSpireFloorState(floor: number, roomIndex: number, totalR
|
||||
armor: guardian.armor || 0,
|
||||
dodgeChance: 0,
|
||||
barrier: 0,
|
||||
element: guardian.element,
|
||||
element: guardian.element.join('+'),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user