feat: Recreate Spire Combat Page — full spire climbing experience
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s

- Add guardian-encounters.ts: Extended guardian definitions for all mana types (compound, exotic, combo) with dynamic name generation
- Add spire-utils.ts: Spire-specific utilities (room generation, enemy stat scaling, insight calculation)
- Add enemy-generator.ts: Enemy generation with combinable modifiers (mage, shield, armored, swarm, agile)
- Add SpireCombatPage/ directory with modular sub-components:
  - SpireHeader.tsx: Floor info, climb controls, exit button, HP/room progress bars
  - RoomDisplay.tsx: Current room info with enemies, barriers, armor, dodge stats
  - SpireCombatControls.tsx: Spell selection panel, golem status panel
  - SpireActivityLog.tsx: Combat activity log
  - SpireManaDisplay.tsx: Compact mana display with elemental pools
- Modify page.tsx: Conditionally render SpireCombatPage when spireMode is true
- Add comprehensive tests (49 tests) for spire utilities, guardian encounters, and enemy generation
This commit is contained in:
2026-05-20 09:28:05 +02:00
parent 1c7fc8c551
commit 7d56fc368f
17 changed files with 2004 additions and 5 deletions
@@ -0,0 +1,131 @@
import { describe, it, expect } from 'vitest';
import {
selectModifiers,
generateEnemy,
generateSwarm,
getModifierDisplay,
getModifierDescription,
} from '../utils/enemy-generator';
const SWARM_CFG = { minEnemies: 3, maxEnemies: 7 };
const SHIELD_AMOUNT = 0.15;
describe('selectModifiers', () => {
it('should return an array', () => {
const mods = selectModifiers(20);
expect(Array.isArray(mods)).toBe(true);
});
it('should return at most 2 modifiers', () => {
for (let i = 0; i < 50; i++) {
const mods = selectModifiers(50);
expect(mods.length).toBeLessThanOrEqual(2);
}
});
it('should return empty array for low floors', () => {
const mods = selectModifiers(1);
expect(mods.length).toBe(0);
});
it('should only return valid modifier types', () => {
const validMods = ['mage', 'shield', 'armored', 'swarm', 'agile'];
for (let floor = 1; floor <= 100; floor++) {
const mods = selectModifiers(floor);
for (const mod of mods) {
expect(validMods).toContain(mod);
}
}
});
});
describe('generateEnemy', () => {
it('should generate enemy with positive HP', () => {
const enemy = generateEnemy(10);
expect(enemy.hp).toBeGreaterThan(0);
expect(enemy.maxHP).toBeGreaterThan(0);
});
it('should include modifiers array', () => {
const enemy = generateEnemy(20);
expect(Array.isArray(enemy.modifiers)).toBe(true);
});
it('should apply armored modifier', () => {
const enemy = generateEnemy(30, ['armored']);
expect(enemy.modifiers).toContain('armored');
expect(enemy.armor).toBeGreaterThan(0);
});
it('should apply agile modifier', () => {
const enemy = generateEnemy(30, ['agile']);
expect(enemy.modifiers).toContain('agile');
expect(enemy.dodgeChance).toBeGreaterThan(0);
});
it('should apply mage modifier', () => {
const enemy = generateEnemy(30, ['mage']);
expect(enemy.modifiers).toContain('mage');
expect(enemy.barrier).toBeGreaterThan(0);
});
it('should apply shield modifier', () => {
const enemy = generateEnemy(30, ['shield']);
expect(enemy.modifiers).toContain('shield');
expect(enemy.barrier).toBeGreaterThanOrEqual(SHIELD_AMOUNT);
});
it('should have valid element', () => {
const enemy = generateEnemy(10);
expect(enemy.element).toBeTruthy();
expect(typeof enemy.element).toBe('string');
});
});
describe('generateSwarm', () => {
it('should generate multiple enemies', () => {
const enemies = generateSwarm(20);
expect(enemies.length).toBeGreaterThanOrEqual(SWARM_CFG.minEnemies);
expect(enemies.length).toBeLessThanOrEqual(SWARM_CFG.maxEnemies);
});
it('each enemy should have reduced HP', () => {
const enemies = generateSwarm(20);
for (const enemy of enemies) {
expect(enemy.hp).toBeGreaterThan(0);
expect(enemy.maxHP).toBeGreaterThan(0);
}
});
it('should include modifiers', () => {
const enemies = generateSwarm(20, ['armored']);
for (const enemy of enemies) {
expect(enemy.modifiers).toContain('armored');
}
});
});
describe('getModifierDisplay', () => {
it('should return display info for all modifiers', () => {
const modifiers = ['mage', 'shield', 'armored', 'swarm', 'agile'] as const;
for (const mod of modifiers) {
const display = getModifierDisplay(mod);
expect(display.label).toBeTruthy();
expect(display.icon).toBeTruthy();
expect(display.color).toBeTruthy();
expect(display.desc).toBeTruthy();
}
});
});
describe('getModifierDescription', () => {
it('should return standard for no modifiers', () => {
expect(getModifierDescription([])).toBe('Standard enemy');
});
it('should return modifier labels', () => {
const desc = getModifierDescription(['armored', 'agile']);
expect(desc).toContain('Armored');
expect(desc).toContain('Agile');
});
});
+270
View File
@@ -0,0 +1,270 @@
import { describe, it, expect } from 'vitest';
import {
getRoomsForFloor,
generateSpireRoomType,
generateSpireFloorState,
getSpireEnemyArmor,
getSpireEnemyBarrier,
calcInsight,
getSpireRoomTypeDisplay,
SPIRE_CONFIG,
} from '../utils/spire-utils';
import { isGuardianFloor, getExtendedGuardian, getGuardianHP, generateGuardianName, generateComboGuardianName, ALL_GUARDIAN_FLOORS } 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
const rooms = getRoomsForFloor(floor);
expect(rooms).toBeGreaterThanOrEqual(SPIRE_CONFIG.minRoomsPerFloor);
expect(rooms).toBeLessThanOrEqual(SPIRE_CONFIG.maxRoomsPerFloor + 5);
}
});
it('should return 1 room for guardian floors', () => {
expect(getRoomsForFloor(10)).toBe(1);
expect(getRoomsForFloor(20)).toBe(1);
expect(getRoomsForFloor(100)).toBe(1);
});
it('should return more rooms for higher non-guardian floors', () => {
const lowFloor = getRoomsForFloor(3);
const highFloor = getRoomsForFloor(79);
expect(highFloor).toBeGreaterThanOrEqual(lowFloor);
});
});
describe('generateSpireRoomType', () => {
it('should return guardian for last room on guardian floors', () => {
const totalRooms = getRoomsForFloor(10);
const roomType = generateSpireRoomType(10, totalRooms - 1, totalRooms);
expect(roomType).toBe('guardian');
});
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);
expect(roomType).toBe('combat');
}
});
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);
expect(roomType).toBe('combat');
});
it('should return valid room types', () => {
const validTypes = ['combat', 'swarm', 'speed', 'guardian', 'puzzle', 'recovery', 'library', 'treasure'];
for (let i = 0; i < 100; i++) {
const roomType = generateSpireRoomType(25, 3, 10);
expect(validTypes).toContain(roomType);
}
});
});
describe('generateSpireFloorState', () => {
it('should generate guardian floor for floor 10', () => {
const state = generateSpireFloorState(10, 0, 1);
expect(state.roomType).toBe('guardian');
expect(state.enemies.length).toBe(1);
expect(state.enemies[0].name).toBeTruthy();
});
it('should generate combat floor with enemies', () => {
const state = generateSpireFloorState(5, 0, 8);
expect(state.enemies.length).toBeGreaterThan(0);
expect(state.enemies[0].hp).toBeGreaterThan(0);
expect(state.enemies[0].maxHP).toBeGreaterThan(0);
});
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);
}
});
});
describe('getSpireEnemyArmor', () => {
it('should return 0 for floors below 10', () => {
for (let i = 0; i < 20; i++) {
const armor = getSpireEnemyArmor(5);
expect(armor).toBeGreaterThanOrEqual(0);
expect(armor).toBeLessThanOrEqual(0.3);
}
});
it('should return values between 0 and 0.3', () => {
for (let floor = 1; floor <= 100; floor++) {
const armor = getSpireEnemyArmor(floor);
expect(armor).toBeGreaterThanOrEqual(0);
expect(armor).toBeLessThanOrEqual(0.3);
}
});
});
describe('getSpireEnemyBarrier', () => {
it('should return 0 for floors below 15', () => {
for (let i = 0; i < 10; i++) {
const barrier = getSpireEnemyBarrier(10, 'fire');
expect(barrier).toBeGreaterThanOrEqual(0);
}
});
it('should return values between 0 and 0.3', () => {
for (let floor = 15; floor <= 100; floor++) {
const barrier = getSpireEnemyBarrier(floor, 'fire');
expect(barrier).toBeGreaterThanOrEqual(0);
expect(barrier).toBeLessThanOrEqual(0.3);
}
});
});
describe('calcInsight', () => {
it('should return positive insight for any floor', () => {
expect(calcInsight(1, false)).toBeGreaterThan(0);
expect(calcInsight(10, true)).toBeGreaterThan(0);
expect(calcInsight(50, false)).toBeGreaterThan(0);
});
it('should give more insight for guardian floors', () => {
const normal = calcInsight(10, false);
const guardian = calcInsight(10, true);
expect(guardian).toBeGreaterThan(normal);
});
it('should scale with floor number', () => {
const low = calcInsight(5, false);
const high = calcInsight(50, false);
expect(high).toBeGreaterThan(low);
});
});
describe('getSpireRoomTypeDisplay', () => {
it('should return display info for all room types', () => {
const types = ['combat', 'swarm', 'speed', 'guardian', 'puzzle', 'recovery', 'library', 'treasure'];
for (const type of types) {
const display = getSpireRoomTypeDisplay(type as any);
expect(display.label).toBeTruthy();
expect(display.icon).toBeTruthy();
expect(display.color).toBeTruthy();
}
});
it('should return unknown for invalid room type', () => {
const display = getSpireRoomTypeDisplay('invalid' as any);
expect(display.label).toBe('Unknown');
});
});
// ─── Guardian Encounters ─────────────────────────────────────────────────────
describe('isGuardianFloor', () => {
it('should return true for every 10th floor', () => {
expect(isGuardianFloor(10)).toBe(true);
expect(isGuardianFloor(20)).toBe(true);
expect(isGuardianFloor(100)).toBe(true);
expect(isGuardianFloor(150)).toBe(true);
});
it('should return false for non-10th floors', () => {
expect(isGuardianFloor(1)).toBe(false);
expect(isGuardianFloor(15)).toBe(false);
expect(isGuardianFloor(99)).toBe(false);
});
});
describe('getExtendedGuardian', () => {
it('should return compound guardians for floors 90, 110', () => {
const g90 = getExtendedGuardian(90);
expect(g90).not.toBeNull();
expect(g90!.element).toBe('metal');
expect(g90!.name).toBeTruthy();
const g110 = getExtendedGuardian(110);
expect(g110).not.toBeNull();
expect(g110!.element).toBe('lightning');
});
it('should return exotic guardians for floors 120, 130, 140', () => {
const g120 = getExtendedGuardian(120);
expect(g120).not.toBeNull();
expect(g120!.element).toBe('crystal');
const g130 = getExtendedGuardian(130);
expect(g130).not.toBeNull();
expect(g130!.element).toBe('stellar');
const g140 = getExtendedGuardian(140);
expect(g140).not.toBeNull();
expect(g140!.element).toBe('void');
});
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 non-guardian floors', () => {
expect(getExtendedGuardian(1)).toBeNull();
expect(getExtendedGuardian(15)).toBeNull();
expect(getExtendedGuardian(95)).toBeNull();
});
});
describe('getGuardianHP', () => {
it('should return positive HP', () => {
expect(getGuardianHP(10)).toBeGreaterThan(0);
expect(getGuardianHP(100)).toBeGreaterThan(0);
expect(getGuardianHP(200)).toBeGreaterThan(0);
});
it('should scale with floor', () => {
const low = getGuardianHP(10);
const high = getGuardianHP(100);
expect(high).toBeGreaterThan(low);
});
});
describe('generateGuardianName', () => {
it('should generate non-empty names', () => {
for (const element of ['fire', 'water', 'air', 'earth', 'light', 'dark', 'death']) {
const name = generateGuardianName(element);
expect(name).toBeTruthy();
expect(name.length).toBeGreaterThan(0);
}
});
it('should include a title', () => {
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', () => {
it('should include base guardian floors', () => {
expect(ALL_GUARDIAN_FLOORS).toContain(10);
expect(ALL_GUARDIAN_FLOORS).toContain(20);
expect(ALL_GUARDIAN_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]);
}
});
});
+278
View File
@@ -0,0 +1,278 @@
// ─── Extended Guardian Encounters ─────────────────────────────────────────────
// Full guardian definitions for all mana types across all spire floors.
// Guardians at floors 10-80: base types, 90-110: compound, 120+: exotic/combination.
import type { GuardianDef } from '../types';
// ─── Name Generation ──────────────────────────────────────────────────────────
const GUARDIAN_PREFIXES: Record<string, string[]> = {
fire: ['Ignis', 'Pyra', 'Sol', 'Vulcan', 'Ember'],
water: ['Aqua', 'Marina', 'Thal', 'Pelag', 'Coral'],
air: ['Ventus', 'Zephyr', 'Aero', 'Nimbus', 'Gale'],
earth: ['Terra', 'Petra', 'Mont', 'Gaia', 'Ore'],
light: ['Lux', 'Solaris', 'Radi', 'Lumin', 'Aur'],
dark: ['Umbra', 'Noct', 'Teneb', 'Ereb', 'Nyx'],
death: ['Mors', 'Necro', 'Than', 'Mort', 'Skull'],
transference: ['Link', 'Arcana', 'Vinc', 'Bind', 'Chain'],
metal: ['Ferr', 'Chroma', 'Steel', 'Arg', 'Ore'],
sand: ['Arena', 'Dune', 'Siroc', 'Erg', 'Sah'],
lightning: ['Volt', 'Fulg', 'Electr', 'Spark', 'Storm'],
crystal: ['Prism', 'Gemma', 'Crystal', 'Shard', 'Facet'],
stellar: ['Astro', 'Stella', 'Nova', 'Cosmo', 'Lumin'],
void: ['Void', 'Abyss', 'Null', 'Nihil', 'Obliv'],
};
const GUARDIAN_TITLES: string[] = [
'Warden', 'Keeper', 'Lord', 'Titan', 'Sovereign',
'Guardian', 'Sentinel', 'Champion', 'Overlord', 'Archon',
];
export function generateGuardianName(element: string): string {
const prefixes = GUARDIAN_PREFIXES[element] || ['Unknown'];
const prefix = prefixes[Math.floor(Math.random() * prefixes.length)];
const title = GUARDIAN_TITLES[Math.floor(Math.random() * GUARDIAN_TITLES.length)];
return `${prefix} the ${title}`;
}
export function generateComboGuardianName(elements: string[]): string {
const parts = elements.map((el) => {
const prefixes = GUARDIAN_PREFIXES[el] || ['Unknown'];
return prefixes[Math.floor(Math.random() * prefixes.length)];
});
const title = GUARDIAN_TITLES[Math.floor(Math.random() * GUARDIAN_TITLES.length)];
return `${parts.join('-')} the ${title}`;
}
// ─── Guardian HP Scaling ──────────────────────────────────────────────────────
export function getGuardianHP(floor: number): number {
// Base scaling: exponential growth per floor
const base = 5000;
const exponent = 1.1 + (floor / 200);
return Math.floor(base * Math.pow(floor / 10, exponent));
}
// ─── Extended Guardian Definitions ────────────────────────────────────────────
// Floors 10-80: Base mana type guardians (already in constants/guardians.ts)
// Floors 90-110: Compound mana type guardians
// Floors 120-140: Exotic mana type guardians
// Floors 150+: Combination guardians
const COMPOUND_GUARDIANS: Record<number, GuardianDef> = {
90: {
name: '', // Generated dynamically
element: 'metal',
hp: getGuardianHP(90),
pact: 3.5,
color: '#BDC3C7',
armor: 0.30,
boons: [
{ type: 'elementalDamage', value: 15, desc: '+15% Metal damage' },
{ type: 'maxMana', value: 150, desc: '+150 max mana' },
],
pactCost: 60000,
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: getGuardianHP(100),
pact: 3.75,
color: '#D4AC0D',
armor: 0.25,
boons: [
{ type: 'elementalDamage', value: 15, desc: '+15% Sand damage' },
{ type: 'manaRegen', value: 1.5, desc: '+1.5 mana regen' },
],
pactCost: 80000,
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: getGuardianHP(110),
pact: 4.0,
color: '#FFEB3B',
armor: 0.22,
boons: [
{ type: 'elementalDamage', value: 15, desc: '+15% Lightning damage' },
{ type: 'castingSpeed', value: 15, desc: '+15% casting speed' },
],
pactCost: 100000,
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,
},
};
const EXOTIC_GUARDIANS: Record<number, GuardianDef> = {
120: {
name: '',
element: 'crystal',
hp: getGuardianHP(120),
pact: 4.5,
color: '#85C1E9',
armor: 0.35,
boons: [
{ 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: 150000,
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: getGuardianHP(130),
pact: 5.0,
color: '#F0E68C',
armor: 0.30,
boons: [
{ type: 'elementalDamage', value: 25, desc: '+25% Stellar damage' },
{ type: 'insightGain', value: 20, desc: '+20% insight gain' },
],
pactCost: 200000,
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: getGuardianHP(140),
pact: 5.5,
color: '#4A235A',
armor: 0.35,
boons: [
{ 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: 300000,
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,
},
};
// ─── Combination Guardians (Floor 150+) ───────────────────────────────────────
const COMBO_PAIRS: [string, string][] = [
['fire', 'water'], // Steam
['fire', 'air'], // Already lightning but different flavor
['water', 'earth'], // Already sand but different flavor
['light', 'dark'], // Twilight
['death', 'light'], // Undeath
['fire', 'death'], // Hellfire
['water', 'dark'], // Abyssal
['air', 'light'], // Radiant wind
['earth', 'death'], // Fossil
];
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);
return {
name: '',
element: `${el1}+${el2}`,
hp,
pact: 6.0 + (floor - 150) * 0.05,
color: '#E8D5F5',
armor,
boons: [
{ type: 'elementalDamage', value: 10, desc: `+10% ${el1} damage` },
{ type: 'elementalDamage', value: 10, desc: `+10% ${el2} damage` },
],
pactCost: Math.floor(hp * 0.5),
pactTime: 20 + Math.floor((floor - 150) / 10),
uniquePerk: `Dual-aspect: ${el1} and ${el2} spells gain +20% effectiveness`,
power: Math.floor(hp * 0.5),
effects: [
{ type: `${el1}_boost`, value: 0.2 },
{ type: `${el2}_boost`, value: 0.2 },
],
signingCost: { mana: Math.floor(hp * 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,
};
}
// ─── Guardian Lookup ──────────────────────────────────────────────────────────
export function getExtendedGuardian(floor: number): GuardianDef | null {
if (COMPOUND_GUARDIANS[floor]) {
const g = { ...COMPOUND_GUARDIANS[floor] };
if (!g.name) g.name = generateGuardianName(g.element);
return g;
}
if (EXOTIC_GUARDIANS[floor]) {
const g = { ...EXOTIC_GUARDIANS[floor] };
if (!g.name) g.name = generateGuardianName(g.element);
return g;
}
if (floor >= 150 && floor % 10 === 0) {
const g = getComboGuardian(floor);
if (!g.name) {
const elements = g.element.split('+');
g.name = generateComboGuardianName(elements);
}
return g;
}
return null;
}
// All guardian floors (extended)
export const ALL_GUARDIAN_FLOORS: number[] = [
10, 20, 30, 40, 50, 60, 80, 100, // Original
90, 110, // Compound
120, 130, 140, // Exotic
...Array.from({ length: 10 }, (_, i) => 150 + i * 10), // Combo
].sort((a, b) => a - b);
// Check if a floor is a guardian floor (every 10th floor)
export function isGuardianFloor(floor: number): boolean {
return floor % 10 === 0;
}
+223
View File
@@ -0,0 +1,223 @@
// ─── Enemy Generator ───────────────────────────────────────────────────────────
// Enemy generation with modifiers: mage, shield, armored, swarm, agile
// Modifiers are combinable (e.g., armored + swarm)
import type { EnemyState } from '../types';
import { getFloorMaxHP, getFloorElement } from './floor-utils';
import { getEnemyName } from './enemy-utils';
// ─── Enemy Modifier Types ─────────────────────────────────────────────────────
export type EnemyModifier = 'mage' | 'shield' | 'armored' | 'swarm' | 'agile';
export interface GeneratedEnemy extends EnemyState {
modifiers: EnemyModifier[];
}
// ─── Modifier Configuration ───────────────────────────────────────────────────
const MODIFIER_CONFIG = {
mage: {
barrierPerFloor: 0.003,
maxBarrier: 0.4,
barrierRechargeRate: 0.05, // Recharges 5% of max HP per tick
},
shield: {
shieldAmount: 0.15, // 15% of max HP as one-time shield
},
armored: {
armorPerFloor: 0.003,
maxArmor: 0.45,
minArmor: 0.1,
},
swarm: {
minEnemies: 3,
maxEnemies: 7,
hpMultiplier: 0.35,
armorPerFloor: 0.002,
},
agile: {
baseDodge: 0.20,
dodgePerFloor: 0.004,
maxDodge: 0.55,
},
};
// ─── Modifier Selection ───────────────────────────────────────────────────────
export function selectModifiers(floor: number): EnemyModifier[] {
const modifiers: EnemyModifier[] = [];
// Mage: appears floor 15+, more common at higher floors
if (floor >= 15 && Math.random() < Math.min(0.3, (floor - 15) * 0.01)) {
modifiers.push('mage');
}
// Shield: appears floor 10+, moderate chance
if (floor >= 10 && Math.random() < Math.min(0.25, (floor - 10) * 0.008)) {
modifiers.push('shield');
}
// Armored: appears floor 5+, common
if (floor >= 5 && Math.random() < Math.min(0.4, (floor - 5) * 0.012)) {
modifiers.push('armored');
}
// Swarm: appears floor 8+, moderate chance
if (floor >= 8 && Math.random() < 0.15) {
modifiers.push('swarm');
}
// Agile: appears floor 12+, moderate chance
if (floor >= 12 && Math.random() < Math.min(0.25, (floor - 12) * 0.008)) {
modifiers.push('agile');
}
// Limit to 2 modifiers max for balance
if (modifiers.length > 2) {
// Shuffle and take first 2
for (let i = modifiers.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[modifiers[i], modifiers[j]] = [modifiers[j], modifiers[i]];
}
return modifiers.slice(0, 2);
}
return modifiers;
}
// ─── Enemy Generation ─────────────────────────────────────────────────────────
export function generateEnemy(floor: number, modifiers?: EnemyModifier[]): GeneratedEnemy {
const element = getFloorElement(floor);
const baseHP = getFloorMaxHP(floor);
const activeModifiers = modifiers || selectModifiers(floor);
let hp = baseHP;
let armor = 0;
let dodgeChance = 0;
let barrier = 0;
let name = getEnemyName(element, floor);
// Apply modifier effects
if (activeModifiers.includes('armored')) {
const progress = Math.min(1, floor / 100);
armor = Math.min(
MODIFIER_CONFIG.armored.maxArmor,
MODIFIER_CONFIG.armored.minArmor + (MODIFIER_CONFIG.armored.maxArmor - MODIFIER_CONFIG.armored.minArmor) * progress
);
name = `Armored ${name}`;
}
if (activeModifiers.includes('agile')) {
dodgeChance = Math.min(
MODIFIER_CONFIG.agile.maxDodge,
MODIFIER_CONFIG.agile.baseDodge + floor * MODIFIER_CONFIG.agile.dodgePerFloor
);
name = `Agile ${name}`;
}
if (activeModifiers.includes('mage')) {
barrier = Math.min(
MODIFIER_CONFIG.mage.maxBarrier,
floor * MODIFIER_CONFIG.mage.barrierPerFloor
);
name = `Mage ${name}`;
}
if (activeModifiers.includes('shield')) {
barrier = Math.max(barrier, MODIFIER_CONFIG.shield.shieldAmount);
name = `${name} (Shielded)`;
}
return {
id: 'enemy',
name,
hp,
maxHP: hp,
armor,
dodgeChance,
barrier,
element,
modifiers: activeModifiers,
};
}
// ─── Swarm Generation ─────────────────────────────────────────────────────────
export function generateSwarm(floor: number, modifiers?: EnemyModifier[]): GeneratedEnemy[] {
const element = getFloorElement(floor);
const baseHP = getFloorMaxHP(floor);
const activeModifiers = modifiers || [];
const numEnemies = MODIFIER_CONFIG.swarm.minEnemies +
Math.floor(Math.random() * (MODIFIER_CONFIG.swarm.maxEnemies - MODIFIER_CONFIG.swarm.minEnemies + 1));
const enemies: GeneratedEnemy[] = [];
for (let i = 0; i < numEnemies; i++) {
const enemyName = getEnemyName(element, floor);
const hp = Math.floor(baseHP * MODIFIER_CONFIG.swarm.hpMultiplier);
const armor = activeModifiers.includes('armored')
? Math.min(0.3, floor * MODIFIER_CONFIG.swarm.armorPerFloor)
: 0;
enemies.push({
id: `swarm_${i}`,
name: `${enemyName} ${i + 1}`,
hp,
maxHP: hp,
armor,
dodgeChance: activeModifiers.includes('agile')
? Math.min(0.35, 0.15 + floor * 0.003)
: 0,
barrier: 0,
element,
modifiers: activeModifiers,
});
}
return enemies;
}
// ─── Modifier Display ─────────────────────────────────────────────────────────
export function getModifierDisplay(modifier: EnemyModifier): { label: string; icon: string; color: string; desc: string } {
const displays: Record<EnemyModifier, { label: string; icon: string; color: string; desc: string }> = {
mage: {
label: 'Mage',
icon: '🔮',
color: '#8B5CF6',
desc: 'Casts barriers that re-apply occasionally',
},
shield: {
label: 'Shielded',
icon: '🛡️',
color: '#3B82F6',
desc: 'Has a one-time shield that must be broken',
},
armored: {
label: 'Armored',
icon: '⛰️',
color: '#F59E0B',
desc: 'Reduces incoming damage',
},
swarm: {
label: 'Swarm',
icon: '🐝',
color: '#EF4444',
desc: 'Multiple weaker enemies',
},
agile: {
label: 'Agile',
icon: '💨',
color: '#10B981',
desc: 'Can dodge attacks',
},
};
return displays[modifier];
}
export function getModifierDescription(modifiers: EnemyModifier[]): string {
if (modifiers.length === 0) return 'Standard enemy';
return modifiers.map((m) => getModifierDisplay(m).label).join(', ');
}
+257
View File
@@ -0,0 +1,257 @@
// ─── Spire Utility Functions ───────────────────────────────────────────────────
// Spire-specific utility functions for room generation, enemy stat scaling, etc.
import type { RoomType, FloorState, EnemyState } from '../types';
import { GUARDIANS, FLOOR_ELEM_CYCLE, PUZZLE_ROOMS } from '../constants';
import { getFloorMaxHP, getFloorElement } from './floor-utils';
import { getEnemyName } from './enemy-utils';
import { isGuardianFloor, getExtendedGuardian } from '../data/guardian-encounters';
// ─── Spire Room Configuration ─────────────────────────────────────────────────
export const SPIRE_CONFIG = {
minRoomsPerFloor: 5,
maxRoomsPerFloor: 15,
guardianRooms: 1,
puzzleRoomChance: 0.12,
rareRoomChance: 0.05,
recoveryRoomChance: 0.4,
libraryRoomChance: 0.3,
treasureRoomChance: 0.3,
};
// ─── Room Count ───────────────────────────────────────────────────────────────
export function getRoomsForFloor(floor: number): number {
if (isGuardianFloor(floor)) return SPIRE_CONFIG.guardianRooms;
const base = SPIRE_CONFIG.minRoomsPerFloor;
const range = SPIRE_CONFIG.maxRoomsPerFloor - SPIRE_CONFIG.minRoomsPerFloor;
// Slight increase in rooms at higher floors
const floorBonus = Math.min(range, Math.floor(floor / 20));
const randomVariation = Math.floor(Math.random() * 3);
return base + floorBonus + randomVariation;
}
// ─── Spire Room Types ─────────────────────────────────────────────────────────
export type SpireRoomType = RoomType | 'recovery' | 'library' | 'treasure';
// ─── Room Generation ──────────────────────────────────────────────────────────
export function generateSpireRoomType(floor: number, roomIndex: number, totalRooms: number): SpireRoomType {
// Last room on guardian floors is always guardian
if (isGuardianFloor(floor) && roomIndex === totalRooms - 1) {
return 'guardian';
}
// First room on a floor is never a special room (always combat)
if (roomIndex === 0) {
return generateCombatRoomType(floor);
}
// Rare rooms (mid-floor)
if (roomIndex === Math.floor(totalRooms / 2) && Math.random() < SPIRE_CONFIG.rareRoomChance) {
return generateRareRoomType();
}
// Puzzle rooms
if (floor % 7 === 0 && Math.random() < SPIRE_CONFIG.puzzleRoomChance) {
return 'puzzle';
}
return generateCombatRoomType(floor);
}
function generateCombatRoomType(floor: number): RoomType {
const roll = Math.random();
if (roll < 0.12) return 'swarm';
if (roll < 0.22) return 'speed';
return 'combat';
}
function generateRareRoomType(): SpireRoomType {
const roll = Math.random();
if (roll < SPIRE_CONFIG.recoveryRoomChance) return 'recovery';
if (roll < SPIRE_CONFIG.recoveryRoomChance + SPIRE_CONFIG.libraryRoomChance) return 'library';
return 'treasure';
}
// ─── Floor State Generation ───────────────────────────────────────────────────
export function generateSpireFloorState(floor: number, roomIndex: number, totalRooms: number): FloorState {
const roomType = generateSpireRoomType(floor, roomIndex, totalRooms);
const element = getFloorElement(floor);
const baseHP = getFloorMaxHP(floor);
switch (roomType) {
case 'guardian': {
const guardian = GUARDIANS[floor] || getExtendedGuardian(floor);
if (guardian) {
return {
roomType: 'guardian',
enemies: [{
id: 'guardian',
name: guardian.name,
hp: guardian.hp,
maxHP: guardian.hp,
armor: guardian.armor || 0,
dodgeChance: 0,
barrier: 0,
element: guardian.element,
}],
};
}
// Fallback if no guardian defined for this floor
return generateCombatRoom(floor, element, baseHP);
}
case 'swarm':
return generateSwarmRoom(floor, element, baseHP);
case 'speed':
return generateSpeedRoom(floor, element, baseHP);
case 'puzzle': {
const puzzleKeys = Object.keys(PUZZLE_ROOMS);
const selectedPuzzle = puzzleKeys[Math.floor(Math.random() * puzzleKeys.length)];
const puzzle = PUZZLE_ROOMS[selectedPuzzle];
return {
roomType: 'puzzle',
enemies: [],
puzzleProgress: 0,
puzzleRequired: 1,
puzzleId: selectedPuzzle,
puzzleAttunements: puzzle.attunements,
};
}
case 'recovery':
return {
roomType: 'recovery',
enemies: [],
recoveryProgress: 0,
recoveryRequired: 1,
} as unknown as FloorState;
case 'library':
return {
roomType: 'library',
enemies: [],
libraryProgress: 0,
libraryRequired: 1,
} as unknown as FloorState;
case 'treasure':
return {
roomType: 'treasure',
enemies: [],
} as unknown as FloorState;
default:
return generateCombatRoom(floor, element, baseHP);
}
}
function generateCombatRoom(floor: number, element: string, baseHP: number): FloorState {
const armor = getSpireEnemyArmor(floor);
const barrier = getSpireEnemyBarrier(floor, element);
const enemyName = getEnemyName(element, floor);
return {
roomType: 'combat',
enemies: [{
id: 'enemy',
name: enemyName,
hp: baseHP,
maxHP: baseHP,
armor,
dodgeChance: 0,
barrier,
element,
}],
};
}
function generateSwarmRoom(floor: number, element: string, baseHP: number): FloorState {
const numEnemies = 3 + Math.floor(Math.random() * 5); // 3-7 enemies
const enemies: EnemyState[] = [];
for (let i = 0; i < numEnemies; i++) {
enemies.push({
id: `swarm_${i}`,
name: `${getEnemyName(element, floor)} ${i + 1}`,
hp: Math.floor(baseHP * 0.35),
maxHP: Math.floor(baseHP * 0.35),
armor: Math.floor(floor / 15) * 0.02,
dodgeChance: 0,
barrier: 0,
element,
});
}
return { roomType: 'swarm', enemies };
}
function generateSpeedRoom(floor: number, element: string, baseHP: number): FloorState {
const dodgeChance = Math.min(0.55, 0.20 + floor * 0.005);
const armor = getSpireEnemyArmor(floor);
return {
roomType: 'speed',
enemies: [{
id: 'agile_enemy',
name: `Agile ${getEnemyName(element, floor)}`,
hp: baseHP,
maxHP: baseHP,
armor,
dodgeChance,
barrier: getSpireEnemyBarrier(floor, element),
element,
}],
};
}
// ─── Enemy Stat Scaling ───────────────────────────────────────────────────────
export function getSpireEnemyArmor(floor: number): number {
if (floor < 10) return 0;
const baseChance = Math.min(0.5, (floor - 10) * 0.01);
if (Math.random() > baseChance) return 0;
const minArmor = 0.05;
const maxArmor = 0.30;
const progress = Math.min(1, (floor - 10) / 90);
return minArmor + (maxArmor - minArmor) * progress * Math.random();
}
export function getSpireEnemyBarrier(floor: number, element: string): number {
if (floor < 15) return 0;
const barrierElements = ['light', 'water', 'earth'];
const baseChance = barrierElements.includes(element) ? 0.12 : 0.06;
const floorBonus = Math.min(0.2, (floor - 15) * 0.003);
if (Math.random() > Math.min(0.35, baseChance + floorBonus)) return 0;
const progress = Math.min(1, (floor - 15) / 85);
return 0.1 + progress * 0.2;
}
// ─── Insight Calculation ──────────────────────────────────────────────────────
export function calcInsight(floor: number, isGuardian: boolean): number {
const base = Math.floor(Math.pow(floor, 1.2));
return isGuardian ? Math.floor(base * 2.5) : base;
}
// ─── Room Type Display ────────────────────────────────────────────────────────
export function getSpireRoomTypeDisplay(roomType: SpireRoomType): { label: string; icon: string; color: string } {
const displays: Record<string, { label: string; icon: string; color: string }> = {
combat: { label: 'Combat', icon: '⚔️', color: '#EF4444' },
swarm: { label: 'Swarm', icon: '🐝', color: '#F59E0B' },
speed: { label: 'Speed', icon: '💨', color: '#3B82F6' },
guardian: { label: 'Guardian', icon: '🛡️', color: '#EF4444' },
puzzle: { label: 'Puzzle', icon: '🧩', color: '#8B5CF6' },
recovery: { label: 'Recovery', icon: '💚', color: '#10B981' },
library: { label: 'Ancient Library', icon: '📚', color: '#6366F1' },
treasure: { label: 'Treasure', icon: '💎', color: '#F59E0B' },
};
return displays[roomType] || { label: 'Unknown', icon: '❓', color: '#6B7280' };
}