1b4e5cf5ac
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
Implements the spec-driven spire multi-room climbing and descent system: - Room navigation: currentRoomIndex, roomsPerFloor, startFloor, exitFloor - Descent tracking: descentPeak, roomResetState, clearedRooms, isDescentComplete - enterDescentMode: snapshots peak, sets climbDirection='down' - advanceRoomOrFloor: room-by-room ascending/descending with floor transitions - onEnterRoomDescend: per-room 50% reset check with auto-skip - onEnterLibraryRoom: discipline XP scaled by floor - Seeded PRNG for deterministic room counts and types - UI: Descend button during ascent, Exit Spire only when isDescentComplete - UI: Room X/Y display, room type badge, in-game time in RoomDisplay - Extracted descent actions to combat-descent-actions.ts (file size limit) - Updated tests for room-aware combat behavior Spec: docs/specs/spire-climbing-spec.md §4.1-§4.9, §6
360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
getRoomsForFloor,
|
|
generateSpireRoomType,
|
|
generateSpireFloorState,
|
|
getSpireEnemyArmor,
|
|
getSpireEnemyBarrier,
|
|
calcInsight,
|
|
getSpireRoomTypeDisplay,
|
|
SPIRE_CONFIG,
|
|
} from '../utils/spire-utils';
|
|
import { isGuardianFloor, getGuardianForFloor, getGuardianHP, generateGuardianName, getAllGuardianFloors } from '../data/guardian-encounters';
|
|
|
|
// ─── Spire Utils ─────────────────────────────────────────────────────────────
|
|
|
|
describe('getRoomsForFloor', () => {
|
|
it('should return at least minRoomsPerFloor for non-guardian floors (seeded)', () => {
|
|
for (let floor = 1; floor <= 50; floor++) {
|
|
if (floor % 10 === 0) continue;
|
|
const rooms = getRoomsForFloor(floor, floor * 12345);
|
|
expect(rooms).toBeGreaterThanOrEqual(SPIRE_CONFIG.minRoomsPerFloor);
|
|
expect(rooms).toBeLessThanOrEqual(SPIRE_CONFIG.minRoomsPerFloor + 10 + 2);
|
|
}
|
|
});
|
|
|
|
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 deterministic results with same seed', () => {
|
|
for (let floor = 1; floor <= 50; floor++) {
|
|
if (floor % 10 === 0) continue;
|
|
const seed = floor * 12345;
|
|
const a = getRoomsForFloor(floor, seed);
|
|
const b = getRoomsForFloor(floor, seed);
|
|
expect(a).toBe(b);
|
|
}
|
|
});
|
|
|
|
it('should return more rooms for higher non-guardian floors (seeded)', () => {
|
|
const lowFloor = getRoomsForFloor(3, 3 * 12345);
|
|
const highFloor = getRoomsForFloor(79, 79 * 12345);
|
|
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 valid room types for any room (spec §4.3)', () => {
|
|
const validTypes = ['combat', 'swarm', 'speed', 'guardian', 'puzzle', 'recovery', 'library', 'treasure'];
|
|
for (const floor of [1, 5, 15, 25]) {
|
|
for (let ri = 0; ri < 10; ri++) {
|
|
const roomType = generateSpireRoomType(floor, ri, 10);
|
|
expect(validTypes).toContain(roomType);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should never return guardian for non-last room on guardian floors', () => {
|
|
for (let i = 0; i < 50; i++) {
|
|
const roomType = generateSpireRoomType(50, 0, 10);
|
|
expect(roomType).not.toBe('guardian');
|
|
}
|
|
});
|
|
|
|
it('should return puzzle on every 7th floor for exactly one room', () => {
|
|
// Floor 7 should have exactly one puzzle room
|
|
let puzzleCount = 0;
|
|
for (let ri = 0; ri < 17; ri++) {
|
|
const roomType = generateSpireRoomType(7, ri, 10);
|
|
if (roomType === 'puzzle') puzzleCount++;
|
|
}
|
|
expect(puzzleCount).toBe(1);
|
|
});
|
|
|
|
it('should produce deterministic results for same floor/roomIndex', () => {
|
|
for (let i = 0; i < 20; i++) {
|
|
const a = generateSpireRoomType(15, 5, 10);
|
|
const b = generateSpireRoomType(15, 5, 10);
|
|
expect(a).toBe(b);
|
|
}
|
|
});
|
|
});
|
|
|
|
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 valid floor state with roomType', () => {
|
|
// With seeded RNG, room 0 on floor 5 may be any valid type.
|
|
// Just verify the state is structurally valid.
|
|
const state = generateSpireFloorState(5, 0, 8);
|
|
expect(state.roomType).toBeTruthy();
|
|
expect(state.enemies).toBeDefined();
|
|
if (state.enemies.length > 0) {
|
|
expect(state.enemies[0].hp).toBeGreaterThan(0);
|
|
expect(state.enemies[0].maxHP).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
|
|
it('should generate combat rooms with enemies having correct HP', () => {
|
|
// Test multiple floors/rooms to find a combat room
|
|
let foundCombat = false;
|
|
for (const floor of [3, 11, 23, 37, 49]) {
|
|
for (let ri = 0; ri < 12; ri++) {
|
|
const state = generateSpireFloorState(floor, ri, 10);
|
|
if (state.roomType === 'combat') {
|
|
expect(state.enemies.length).toBe(1);
|
|
expect(state.enemies[0].hp).toBeGreaterThan(0);
|
|
expect(state.enemies[0].maxHP).toBe(state.enemies[0].hp);
|
|
foundCombat = true;
|
|
break;
|
|
}
|
|
}
|
|
if (foundCombat) break;
|
|
}
|
|
expect(foundCombat).toBe(true);
|
|
});
|
|
|
|
it('should generate swarm floor with multiple enemies', () => {
|
|
// Test multiple rooms to find a swarm room
|
|
let foundSwarm = false;
|
|
for (let ri = 1; ri < 15; ri++) {
|
|
const state = generateSpireFloorState(20, ri, 12);
|
|
if (state.roomType === 'swarm') {
|
|
expect(state.enemies.length).toBeGreaterThanOrEqual(3);
|
|
foundSwarm = true;
|
|
break;
|
|
}
|
|
}
|
|
expect(foundSwarm).toBe(true);
|
|
});
|
|
|
|
it('should generate library rooms with no enemies', () => {
|
|
// Test multiple rooms to find a library room
|
|
let foundLibrary = false;
|
|
for (const floor of [1, 5, 11, 19, 23]) {
|
|
for (let ri = 1; ri < 15; ri++) {
|
|
const state = generateSpireFloorState(floor, ri, 12);
|
|
if (state.roomType === 'library') {
|
|
expect(state.enemies.length).toBe(0);
|
|
expect(state.libraryProgress).toBeDefined();
|
|
foundLibrary = true;
|
|
break;
|
|
}
|
|
}
|
|
if (foundLibrary) break;
|
|
}
|
|
expect(foundLibrary).toBe(true);
|
|
});
|
|
});
|
|
|
|
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.3000000001);
|
|
}
|
|
});
|
|
});
|
|
|
|
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('getGuardianForFloor (unified lookup)', () => {
|
|
it('should return base element guardians for floors 10-70', () => {
|
|
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).toEqual(['transference']);
|
|
});
|
|
|
|
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 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 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', () => {
|
|
expect(getGuardianForFloor(1)).toBeNull();
|
|
expect(getGuardianForFloor(15)).toBeNull();
|
|
expect(getGuardianForFloor(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('getAllGuardianFloors', () => {
|
|
it('should include base guardian floors', () => {
|
|
const floors = getAllGuardianFloors();
|
|
expect(floors).toContain(10);
|
|
expect(floors).toContain(20);
|
|
expect(floors).toContain(100);
|
|
});
|
|
|
|
it('should be sorted', () => {
|
|
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);
|
|
});
|
|
});
|