feat: Recreate Spire Combat Page — full spire climbing experience
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user