feat: implement spire descent system with room-aware navigation
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
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
This commit is contained in:
@@ -14,12 +14,12 @@ import { isGuardianFloor, getGuardianForFloor, getGuardianHP, generateGuardianNa
|
||||
// ─── Spire Utils ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getRoomsForFloor', () => {
|
||||
it('should return at least minRoomsPerFloor for non-guardian floors', () => {
|
||||
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);
|
||||
const rooms = getRoomsForFloor(floor, floor * 12345);
|
||||
expect(rooms).toBeGreaterThanOrEqual(SPIRE_CONFIG.minRoomsPerFloor);
|
||||
expect(rooms).toBeLessThanOrEqual(SPIRE_CONFIG.maxRoomsPerFloor + 5);
|
||||
expect(rooms).toBeLessThanOrEqual(SPIRE_CONFIG.minRoomsPerFloor + 10 + 2);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -29,9 +29,19 @@ describe('getRoomsForFloor', () => {
|
||||
expect(getRoomsForFloor(100)).toBe(1);
|
||||
});
|
||||
|
||||
it('should return more rooms for higher non-guardian floors', () => {
|
||||
const lowFloor = getRoomsForFloor(3);
|
||||
const highFloor = getRoomsForFloor(79);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -43,27 +53,38 @@ describe('generateSpireRoomType', () => {
|
||||
expect(roomType).toBe('guardian');
|
||||
});
|
||||
|
||||
it('should return combat for first room on non-guardian floors', () => {
|
||||
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]) {
|
||||
const roomType = generateSpireRoomType(floor, 0, 10);
|
||||
expect(['combat', 'swarm', 'speed']).toContain(roomType);
|
||||
for (let ri = 0; ri < 10; ri++) {
|
||||
const roomType = generateSpireRoomType(floor, ri, 10);
|
||||
expect(validTypes).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'
|
||||
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(['combat', 'swarm', 'speed']).toContain(roomType);
|
||||
expect(roomType).not.toBe('guardian');
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -76,18 +97,67 @@ describe('generateSpireFloorState', () => {
|
||||
expect(state.enemies[0].name).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should generate combat floor with enemies', () => {
|
||||
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.enemies.length).toBeGreaterThan(0);
|
||||
expect(state.enemies[0].hp).toBeGreaterThan(0);
|
||||
expect(state.enemies[0].maxHP).toBeGreaterThan(0);
|
||||
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', () => {
|
||||
const state = generateSpireFloorState(20, 1, 10);
|
||||
if (state.roomType === 'swarm') {
|
||||
expect(state.enemies.length).toBeGreaterThanOrEqual(3);
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user