diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 841ea77..8407feb 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,4 +1,4 @@ # Circular Dependencies -Generated: 2026-05-29T13:23:45.664Z +Generated: 2026-05-29T13:42:14.414Z No circular dependencies found. ✅ diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index e342804..a7840df 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-05-29T13:23:43.981Z", + "generated": "2026-05-29T13:42:12.691Z", "description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry." }, diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 5277d4a..c31cd77 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -371,6 +371,7 @@ Mana-Loop/ │ │ │ │ ├── enemy-utils.ts │ │ │ │ ├── floor-utils.ts │ │ │ │ ├── formatting.ts +│ │ │ │ ├── guardian-utils.ts │ │ │ │ ├── index.ts │ │ │ │ ├── mana-utils.ts │ │ │ │ ├── pact-utils.ts diff --git a/src/components/game/debug/PactDebug.tsx b/src/components/game/debug/PactDebug.tsx index 5bedf43..f61545a 100644 --- a/src/components/game/debug/PactDebug.tsx +++ b/src/components/game/debug/PactDebug.tsx @@ -34,7 +34,7 @@ function GuardianPactRow({ floor, isSigned, onForceSign, onRemove }: { Floor {floor} | {guardian.pact}x multiplier
- Element: {ELEMENTS[guardian.element]?.name || guardian.element} + Element: {guardian.element.map(el => ELEMENTS[el]?.name || el).join(' + ')}
@@ -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, }, diff --git a/src/components/game/tabs/DebugTab/PactDebugSection.tsx b/src/components/game/tabs/DebugTab/PactDebugSection.tsx index ecc543f..341e4d3 100644 --- a/src/components/game/tabs/DebugTab/PactDebugSection.tsx +++ b/src/components/game/tabs/DebugTab/PactDebugSection.tsx @@ -34,7 +34,7 @@ function GuardianPactRow({ floor, isSigned, onForceSign, onRemove }: { Floor {floor} | {guardian.pact}x multiplier
- Element: {ELEMENTS[guardian.element]?.name || guardian.element} + Element: {guardian.element.map(el => ELEMENTS[el]?.name || el).join(' + ')}
@@ -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, }, diff --git a/src/components/game/tabs/SpireSummaryTab.test.ts b/src/components/game/tabs/SpireSummaryTab.test.ts index ad659b6..0bedc03 100644 --- a/src/components/game/tabs/SpireSummaryTab.test.ts +++ b/src/components/game/tabs/SpireSummaryTab.test.ts @@ -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'); diff --git a/src/components/game/tabs/SpireSummaryTab.tsx b/src/components/game/tabs/SpireSummaryTab.tsx index 3cb95d1..c690845 100644 --- a/src/components/game/tabs/SpireSummaryTab.tsx +++ b/src/components/game/tabs/SpireSummaryTab.tsx @@ -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 - {nextGuardianData.element} + {nextGuardianData.element.join(' + ')} HP: {fmt(nextGuardianData.hp)} {nextGuardianData.armor && ( @@ -287,7 +287,7 @@ function GuardianRosterItem({ floor, guardian, isDefeated }: { floor: number; gu color: guardian.color, }} > - {guardian.element} + {guardian.element.join(' + ')} HP: {fmt(guardian.hp)}
diff --git a/src/components/game/tabs/guardian-pacts-components.tsx b/src/components/game/tabs/guardian-pacts-components.tsx index 9428454..9adc48a 100644 --- a/src/components/game/tabs/guardian-pacts-components.tsx +++ b/src/components/game/tabs/guardian-pacts-components.tsx @@ -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 = 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 ( diff --git a/src/lib/game/__tests__/guardian-names.test.ts b/src/lib/game/__tests__/guardian-names.test.ts index 512a4ab..2f5912a 100644 --- a/src/lib/game/__tests__/guardian-names.test.ts +++ b/src/lib/game/__tests__/guardian-names.test.ts @@ -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 " 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); }); }); diff --git a/src/lib/game/__tests__/room-utils-floor-state.test.ts b/src/lib/game/__tests__/room-utils-floor-state.test.ts index c01c9c5..f2d5070 100644 --- a/src/lib/game/__tests__/room-utils-floor-state.test.ts +++ b/src/lib/game/__tests__/room-utils-floor-state.test.ts @@ -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', () => { diff --git a/src/lib/game/__tests__/spire-utils.test.ts b/src/lib/game/__tests__/spire-utils.test.ts index cd440bc..5e26757 100644 --- a/src/lib/game/__tests__/spire-utils.test.ts +++ b/src/lib/game/__tests__/spire-utils.test.ts @@ -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); + }); }); diff --git a/src/lib/game/__tests__/store-actions-combat-prestige.test.ts b/src/lib/game/__tests__/store-actions-combat-prestige.test.ts index 7626979..0cd0595 100644 --- a/src/lib/game/__tests__/store-actions-combat-prestige.test.ts +++ b/src/lib/game/__tests__/store-actions-combat-prestige.test.ts @@ -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); }); diff --git a/src/lib/game/data/guardian-data.ts b/src/lib/game/data/guardian-data.ts index ee8173c..b754ec2 100644 --- a/src/lib/game/data/guardian-data.ts +++ b/src/lib/game/data/guardian-data.ts @@ -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 }; diff --git a/src/lib/game/data/guardian-encounters.ts b/src/lib/game/data/guardian-encounters.ts index 0484181..e9f4292 100644 --- a/src/lib/game/data/guardian-encounters.ts +++ b/src/lib/game/data/guardian-encounters.ts @@ -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; } diff --git a/src/lib/game/types/attunements.ts b/src/lib/game/types/attunements.ts index 4dcc391..059d817 100644 --- a/src/lib/game/types/attunements.ts +++ b/src/lib/game/types/attunements.ts @@ -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; diff --git a/src/lib/game/utils/combat-utils.ts b/src/lib/game/utils/combat-utils.ts index f4bd0bb..1da8fef 100644 --- a/src/lib/game/utils/combat-utils.ts +++ b/src/lib/game/utils/combat-utils.ts @@ -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 diff --git a/src/lib/game/utils/guardian-utils.ts b/src/lib/game/utils/guardian-utils.ts new file mode 100644 index 0000000..0a431aa --- /dev/null +++ b/src/lib/game/utils/guardian-utils.ts @@ -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); +} diff --git a/src/lib/game/utils/room-utils.ts b/src/lib/game/utils/room-utils.ts index 1302901..6042675 100644 --- a/src/lib/game/utils/room-utils.ts +++ b/src/lib/game/utils/room-utils.ts @@ -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('+'), }], }; diff --git a/src/lib/game/utils/spire-utils.ts b/src/lib/game/utils/spire-utils.ts index 2bbf0fa..8bf0f80 100644 --- a/src/lib/game/utils/spire-utils.ts +++ b/src/lib/game/utils/spire-utils.ts @@ -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('+'), }], }; }