feat: implement spire descent system with room-aware navigation
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:
2026-06-03 12:40:42 +02:00
parent feae6b468d
commit 1b4e5cf5ac
13 changed files with 638 additions and 126 deletions
+22 -9
View File
@@ -128,19 +128,27 @@ export function processCombatTick(
castProgress -= 1;
safetyCounter++;
// Check if floor is cleared
// Check if room/floor is cleared
if (floorHP <= 0) {
const guardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!guardian);
currentFloor = Math.min(currentFloor + 1, 100);
floorMaxHP = getFloorMaxHP(currentFloor);
floorHP = floorMaxHP;
// ── Spec: room-aware advancement (climbing spec §4.4) ──────────────
// Instead of directly incrementing the floor, delegate to the store's
// advanceRoomOrFloor which handles room-by-room and floor transitions.
get().advanceRoomOrFloor();
// Re-read state after advancement
const newState = get();
currentFloor = newState.currentFloor;
floorMaxHP = newState.floorMaxHP;
floorHP = newState.floorHP;
castProgress = 0;
if (guardian) {
logMessages.push(`\u2694\ufe0f ${guardian.name} defeated!`);
} else if (currentFloor % 5 === 0) {
logMessages.push(`🏰 Floor ${currentFloor - 1} cleared!`);
logMessages.push(`\ud83c\udff0 Floor ${currentFloor - 1} cleared!`);
}
}
}
@@ -197,14 +205,19 @@ export function processCombatTick(
if (floorHP <= 0) {
const eGuardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!eGuardian);
currentFloor = Math.min(currentFloor + 1, 100);
floorMaxHP = getFloorMaxHP(currentFloor);
floorHP = floorMaxHP;
// ── Spec: room-aware advancement ─────────────────────────────────
get().advanceRoomOrFloor();
const newState = get();
currentFloor = newState.currentFloor;
floorMaxHP = newState.floorMaxHP;
floorHP = newState.floorHP;
eCastProgress = 0;
if (eGuardian) {
logMessages.push(`\u2694\ufe0f ${eGuardian.name} defeated!`);
} else if (currentFloor % 5 === 0) {
logMessages.push(`🏰 Floor ${currentFloor - 1} cleared!`);
logMessages.push(`\ud83c\udff0 Floor ${currentFloor - 1} cleared!`);
}
break;
}
@@ -0,0 +1,248 @@
// ─── Combat Descent Actions ────────────────────────────────────────────────────
// Extracted from combatStore.ts to keep the store under the 400-line limit.
// Implements the spec-driven descent system (climbing spec §4.4–§4.9).
import type { CombatState, CombatStore } from './combat-state.types';
import { generateSpireFloorState, getRoomsForFloor } from '../utils/spire-utils';
import { getGuardianForFloor } from '../data/guardian-encounters';
import { usePrestigeStore } from './prestigeStore';
import { useDisciplineStore } from './discipline-slice';
import type { FloorState } from '../types';
type GetFn = () => CombatStore;
type SetFn = (state: Partial<CombatState>) => void;
/** Helper: compute total enemy HP from a room */
function calcRoomHP(room: { enemies?: Array<{ hp: number }> }): number {
if (!room.enemies || room.enemies.length === 0) return 0;
return room.enemies.reduce((sum, e) => sum + e.hp, 0);
}
// ─── enterDescentMode (climbing spec §4.5) ────────────────────────────────────
export function enterDescentMode(get: GetFn, set: SetFn): void {
const s = get();
set({
climbDirection: 'down',
descentPeak: { floor: s.currentFloor, roomIndex: s.currentRoomIndex },
isDescentComplete: false,
});
get().addActivityLog('floor_transition',
`Beginning descent from Floor ${s.currentFloor}, Room ${s.currentRoomIndex + 1}`);
// Start descending from the current room
onEnterRoomDescend(get, set);
}
// ─── advanceRoomOrFloor (climbing spec §4.4, §4.6) ────────────────────────────
export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
const s = get();
if (s.climbDirection === 'down') {
// ── Descending (spec §4.6) ────────────────────────────────────────────
get().addActivityLog('floor_transition', `Room ${s.currentRoomIndex + 1} passed`);
if (s.currentFloor <= s.exitFloor && s.currentRoomIndex <= 0) {
set({ isDescentComplete: true });
get().addActivityLog('floor_transition',
'Descent complete — Exit Spire is now available');
return;
}
if (s.currentRoomIndex <= 0) {
const newFloor = s.currentFloor - 1;
const newRoomsPerFloor = getRoomsForFloor(newFloor, newFloor * 12345);
const newRoomIndex = newRoomsPerFloor - 1;
const newRoom = generateSpireFloorState(newFloor, newRoomIndex, newRoomsPerFloor);
const newFloorHP = calcRoomHP(newRoom);
set({
currentFloor: newFloor,
currentRoomIndex: newRoomIndex,
roomsPerFloor: newRoomsPerFloor,
currentRoom: newRoom,
floorHP: newFloorHP,
floorMaxHP: newFloorHP,
castProgress: 0,
});
get().addActivityLog('floor_transition', `Descended to Floor ${newFloor}`);
} else {
const newRoomIndex = s.currentRoomIndex - 1;
const newRoom = generateSpireFloorState(s.currentFloor, newRoomIndex, s.roomsPerFloor);
const newFloorHP = calcRoomHP(newRoom);
set({
currentRoomIndex: newRoomIndex,
currentRoom: newRoom,
floorHP: newFloorHP,
floorMaxHP: newFloorHP,
castProgress: 0,
});
}
onEnterRoomDescend(get, set);
} else {
// ── Ascending (spec §4.4) ─────────────────────────────────────────────
const roomKey = `${s.currentFloor}:${s.currentRoomIndex}`;
set((prev) => ({
clearedRooms: { ...prev.clearedRooms, [roomKey]: true },
}));
get().addActivityLog('floor_transition',
`Floor ${s.currentFloor} Room ${s.currentRoomIndex + 1}/${s.roomsPerFloor} cleared`);
if (s.currentRoomIndex + 1 >= s.roomsPerFloor) {
const newFloor = Math.min(s.currentFloor + 1, 100);
const newRoomsPerFloor = getRoomsForFloor(newFloor, newFloor * 12345);
const newRoom = generateSpireFloorState(newFloor, 0, newRoomsPerFloor);
const newFloorHP = calcRoomHP(newRoom);
set({
currentFloor: newFloor,
currentRoomIndex: 0,
roomsPerFloor: newRoomsPerFloor,
currentRoom: newRoom,
floorHP: newFloorHP,
floorMaxHP: newFloorHP,
castProgress: 0,
});
get().addActivityLog('floor_transition', `Ascending to Floor ${newFloor}`);
} else {
const newRoomIndex = s.currentRoomIndex + 1;
const newRoom = generateSpireFloorState(s.currentFloor, newRoomIndex, s.roomsPerFloor);
const newFloorHP = calcRoomHP(newRoom);
set({
currentRoomIndex: newRoomIndex,
currentRoom: newRoom,
floorHP: newFloorHP,
floorMaxHP: newFloorHP,
castProgress: 0,
});
}
// Handle non-combat rooms on ascent
const room = get().currentRoom;
if (room.roomType === 'library') {
onEnterLibraryRoom(get, set);
} else if (room.roomType === 'recovery' || room.roomType === 'treasure' || room.roomType === 'puzzle') {
advanceRoomOrFloor(get, set);
}
}
}
// ─── onEnterRoomDescend (climbing spec §4.7) ──────────────────────────────────
export function onEnterRoomDescend(get: GetFn, set: SetFn): void {
const s = get();
const key = `${s.currentFloor}:${s.currentRoomIndex}`;
if (s.roomResetState[key] === undefined) {
set((prev) => ({
roomResetState: { ...prev.roomResetState, [key]: Math.random() < 0.5 },
}));
}
const wasCleared = s.clearedRooms[key] === true;
if (!wasCleared) {
get().addActivityLog('floor_transition',
`Floor ${s.currentFloor} Room ${s.currentRoomIndex + 1} was not cleared — enemies present`);
return;
}
const didReset = get().roomResetState[key];
if (didReset) {
const newRoom = generateSpireFloorState(s.currentFloor, s.currentRoomIndex, s.roomsPerFloor);
set({ currentRoom: newRoom, castProgress: 0 });
get().addActivityLog('floor_transition',
`Floor ${s.currentFloor} Room ${s.currentRoomIndex + 1} has reset — enemies respawned`);
if (newRoom.roomType === 'guardian') {
const guardian = getGuardianForFloor(s.currentFloor);
if (guardian) {
set({
guardianShield: guardian.shield ?? 0,
guardianShieldMax: guardian.shield ?? 0,
guardianBarrier: guardian.barrier ?? 0,
guardianBarrierMax: guardian.barrier ?? 0,
});
}
}
} else {
get().addActivityLog('floor_transition',
`Floor ${s.currentFloor} Room ${s.currentRoomIndex + 1} is clear — moving on`);
advanceRoomOrFloor(get, set);
}
}
// ─── onEnterLibraryRoom (climbing spec §4.8) ──────────────────────────────────
export function onEnterLibraryRoom(get: GetFn, set: SetFn): void {
const s = get();
const BASE_LIBRARY_XP = 50;
const xpGrant = Math.floor(BASE_LIBRARY_XP * (1 + s.currentFloor / 10));
const disciplineStore = useDisciplineStore.getState();
const activeIds = disciplineStore.activeIds || [];
const allDisciplines = disciplineStore.disciplines;
const activeEntries = activeIds
.map((id: string) => [id, allDisciplines[id]] as const)
.filter(([, ds]) => ds != null);
const targetPool = activeEntries.length > 0
? activeEntries
: Object.entries(allDisciplines);
if (targetPool.length > 0) {
const [targetId, targetDs] = targetPool[Math.floor(Math.random() * targetPool.length)];
useDisciplineStore.setState((prev) => ({
disciplines: {
...prev.disciplines,
[targetId]: { ...targetDs, xp: (targetDs?.xp || 0) + xpGrant },
},
totalXP: prev.totalXP + xpGrant,
}));
const discName = targetDs?.id || targetId;
get().addActivityLog('special_effect',
`${discName} gained ${xpGrant} XP from ancient tome`);
}
get().addActivityLog('floor_transition',
`Entered library room on Floor ${s.currentFloor}`);
advanceRoomOrFloor(get, set);
}
// ─── enterSpireMode (climbing spec §4.1) ──────────────────────────────────────
export function createEnterSpireMode(get: GetFn, set: SetFn, generateFloorState: (floor: number) => FloorState) {
return () => {
const prestigeStore = usePrestigeStore.getState();
const spireKey = prestigeStore.prestigeUpgrades.spireKey || 0;
const startFloor = 1 + (spireKey * 2);
const seed = startFloor * 12345;
const rooms = getRoomsForFloor(startFloor, seed);
const freshRoom = generateSpireFloorState(startFloor, 0, rooms);
set({
spireMode: true,
currentAction: 'climb',
currentFloor: startFloor,
startFloor,
exitFloor: startFloor,
currentRoomIndex: 0,
roomsPerFloor: rooms,
floorHP: calcRoomHP(freshRoom),
floorMaxHP: calcRoomHP(freshRoom),
currentRoom: freshRoom,
castProgress: 0,
climbDirection: 'up',
isDescending: false,
clearedFloors: {},
clearedRooms: {},
roomResetState: {},
descentPeak: null,
isDescentComplete: false,
});
get().addActivityLog('floor_transition',
`Entered the Spire at Floor ${startFloor}`);
};
}
+30
View File
@@ -28,6 +28,26 @@ export interface CombatState {
climbDirection: 'up' | 'down' | null;
isDescending: boolean;
// ─── Spec: Room navigation (climbing spec §6) ───────────────────────────
/** Floor the player entered at (= 1 + spireKey × 2) */
startFloor: number;
/** Floor the player must reach on descent to exit (= startFloor) */
exitFloor: number;
/** 0-indexed room within currentFloor */
currentRoomIndex: number;
/** Total rooms on currentFloor (deterministic via seed) */
roomsPerFloor: number;
// ─── Spec: Descent tracking (climbing spec §6) ──────────────────────────
/** Snapshot of peak floor/room when descent was initiated */
descentPeak: { floor: number; roomIndex: number } | null;
/** Per-room reset rolls, lazily populated on descent. Key = "floor:roomIndex" */
roomResetState: Record<string, boolean>;
/** Tracks which floor:roomIndex pairs were cleared on ascent */
clearedRooms: Record<string, boolean>;
/** True when player has reached exitFloor R0 during descent */
isDescentComplete: boolean;
// Golemancy (summoned golems)
golemancy: GolemancyState;
@@ -85,6 +105,16 @@ export interface CombatActions {
startPracticing: () => void;
stopPracticing: () => void;
// ─── Spec: New descent actions (climbing spec §2.2) ─────────────────────
/** Snapshot peak floor/room, set climbDirection = 'down', begin reverse traversal */
enterDescentMode: () => void;
/** Move to next room/floor (ascending) or previous room/floor (descending) */
advanceRoomOrFloor: () => void;
/** Check per-room reset on descent entry; auto-skip or re-generate enemies */
onEnterRoomDescend: () => void;
/** Grant discipline XP scaled by floor, then advance */
onEnterLibraryRoom: () => void;
// Golemancy
toggleGolem: (golemId: string) => void;
setEnabledGolems: (golemIds: string[]) => void;
+36 -24
View File
@@ -7,11 +7,17 @@ import { createSafeStorage } from '../utils/safe-persist';
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType } from '../types';
import { getFloorMaxHP } from '../utils';
import { generateFloorState } from '../utils/room-utils';
import { generateSpireFloorState } from '../utils/spire-utils';
import { addActivityLogEntry } from '../utils/activity-log';
import { processCombatTick, makeInitialSpells } from './combat-actions';
import { getGuardianForFloor } from '../data/guardian-encounters';
import type { CombatStore } from './combat-state.types';
import {
enterDescentMode,
advanceRoomOrFloor,
onEnterRoomDescend,
onEnterLibraryRoom,
createEnterSpireMode,
} from './combat-descent-actions';
export const useCombatStore = create<CombatStore>()(
persist(
@@ -33,6 +39,18 @@ export const useCombatStore = create<CombatStore>()(
climbDirection: null,
isDescending: false,
// ─── Spec: Room navigation state ──────────────────────────────────────
startFloor: 1,
exitFloor: 1,
currentRoomIndex: 0,
roomsPerFloor: 1,
// ─── Spec: Descent tracking state ─────────────────────────────────────
descentPeak: null,
roomResetState: {},
clearedRooms: {},
isDescentComplete: false,
// Golemancy
golemancy: {
enabledGolems: [],
@@ -158,13 +176,18 @@ export const useCombatStore = create<CombatStore>()(
currentAction: 'meditate',
climbDirection: null,
isDescending: false,
currentFloor: 1,
floorHP: getFloorMaxHP(1),
floorMaxHP: getFloorMaxHP(1),
currentRoom: generateFloorState(1),
currentFloor: s.exitFloor,
floorHP: getFloorMaxHP(s.exitFloor),
floorMaxHP: getFloorMaxHP(s.exitFloor),
currentRoom: generateFloorState(s.exitFloor),
castProgress: 0,
clearedFloors: {},
// Preserve maxFloorReached — don't reset to 0 on spire exit (fix #238)
clearedRooms: {},
roomResetState: {},
descentPeak: null,
isDescentComplete: false,
currentRoomIndex: 0,
roomsPerFloor: 1,
maxFloorReached: Math.max(s.maxFloorReached, 1),
}));
},
@@ -174,17 +197,21 @@ export const useCombatStore = create<CombatStore>()(
startClimbDown: () => set({ climbDirection: 'down', currentAction: 'climb' }),
startPracticing: () => set((s) => {
// Only override if the current action is 'meditate' — don't clobber climb/study/etc.
if (s.currentAction !== 'meditate') return s;
return { currentAction: 'practicing' };
}),
stopPracticing: () => set((s) => {
// Only restore to meditate if we're currently practicing (don't clobber other actions)
if (s.currentAction !== 'practicing') return s;
return { currentAction: 'meditate' };
}),
// ─── Spec: Descent actions (delegated to combat-descent-actions.ts) ────
enterDescentMode: () => enterDescentMode(get, set),
advanceRoomOrFloor: () => advanceRoomOrFloor(get, set),
onEnterRoomDescend: () => onEnterRoomDescend(get, set),
onEnterLibraryRoom: () => onEnterLibraryRoom(get, set),
// Golemancy
toggleGolem: (golemId: string) => {
set((s) => {
@@ -207,22 +234,7 @@ export const useCombatStore = create<CombatStore>()(
}));
},
enterSpireMode: () => {
const freshRoom = generateSpireFloorState(1, 0, 1);
set((s) => ({
spireMode: true,
currentAction: 'climb',
currentFloor: 1,
floorHP: getFloorMaxHP(1),
floorMaxHP: getFloorMaxHP(1),
currentRoom: freshRoom,
castProgress: 0,
climbDirection: null,
isDescending: false,
clearedFloors: {},
// Don't inflate maxFloorReached — it should reflect actual progress (fix #238)
}));
},
enterSpireMode: createEnterSpireMode(get, set, generateFloorState),
learnSpell: (spellId: string) => {
set((state) => ({