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:
@@ -26,8 +26,16 @@ function resetStores() {
|
||||
spireMode: false,
|
||||
currentRoom: { roomType: 'combat', enemies: [] },
|
||||
clearedFloors: {},
|
||||
climbDirection: null,
|
||||
climbDirection: 'up',
|
||||
isDescending: false,
|
||||
startFloor: 1,
|
||||
exitFloor: 1,
|
||||
currentRoomIndex: 0,
|
||||
roomsPerFloor: 5,
|
||||
descentPeak: null,
|
||||
roomResetState: {},
|
||||
clearedRooms: {},
|
||||
isDescentComplete: false,
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 },
|
||||
equipmentSpellStates: [],
|
||||
comboHitCount: 0,
|
||||
@@ -103,13 +111,25 @@ describe('processCombatTick', () => {
|
||||
});
|
||||
|
||||
describe('floor advancement', () => {
|
||||
it('should advance floor when HP reaches 0', () => {
|
||||
it('should advance room when HP reaches 0 (not last room)', () => {
|
||||
// Set floor HP very low so it's cleared in one cast
|
||||
useCombatStore.setState({ floorHP: 1, castProgress: 0.99 });
|
||||
// currentRoomIndex=0, roomsPerFloor=5 → clears room 0, advances to room 1
|
||||
useCombatStore.setState({ floorHP: 1, castProgress: 0.99, climbDirection: 'up', currentRoomIndex: 0, roomsPerFloor: 5 });
|
||||
const elements = makeInitialElements(500, {});
|
||||
const result = runCombatTick(1000, elements);
|
||||
// Room advanced, floor stays the same
|
||||
expect(result.currentFloor).toBe(1);
|
||||
// floorHP should be the new room's enemy HP
|
||||
expect(result.floorHP).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should advance floor when last room on floor is cleared', () => {
|
||||
// Set currentRoomIndex to last room so clearing it advances the floor
|
||||
useCombatStore.setState({ floorHP: 1, castProgress: 0.99, climbDirection: 'up', currentRoomIndex: 4, roomsPerFloor: 5 });
|
||||
const elements = makeInitialElements(500, {});
|
||||
const result = runCombatTick(1000, elements);
|
||||
expect(result.currentFloor).toBe(2);
|
||||
expect(result.floorHP).toBe(getFloorMaxHP(2));
|
||||
expect(result.floorHP).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should update maxFloorReached when advancing', () => {
|
||||
|
||||
@@ -14,6 +14,9 @@ describe('Cross-Module: Combat & Meditation', () => {
|
||||
useManaStore.setState({ rawMana: 9999 });
|
||||
useCombatStore.setState({
|
||||
currentAction: 'climb',
|
||||
climbDirection: 'up',
|
||||
currentRoomIndex: 0,
|
||||
roomsPerFloor: 1,
|
||||
currentFloor: 1,
|
||||
floorHP: getFloorMaxHP(1),
|
||||
floorMaxHP: getFloorMaxHP(1),
|
||||
@@ -45,6 +48,9 @@ describe('Cross-Module: Combat & Meditation', () => {
|
||||
it('should track maxFloorReached across multiple floors', () => {
|
||||
useCombatStore.setState({
|
||||
currentAction: 'climb',
|
||||
climbDirection: 'up',
|
||||
currentRoomIndex: 0,
|
||||
roomsPerFloor: 1,
|
||||
currentFloor: 1,
|
||||
floorHP: getFloorMaxHP(1),
|
||||
floorMaxHP: getFloorMaxHP(1),
|
||||
@@ -61,6 +67,9 @@ describe('Cross-Module: Combat & Meditation', () => {
|
||||
it('should cap maxFloorReached at 100', () => {
|
||||
useCombatStore.setState({
|
||||
currentAction: 'climb',
|
||||
climbDirection: 'up',
|
||||
currentRoomIndex: 0,
|
||||
roomsPerFloor: 1,
|
||||
currentFloor: 100,
|
||||
floorHP: getFloorMaxHP(100),
|
||||
floorMaxHP: getFloorMaxHP(100),
|
||||
@@ -78,6 +87,9 @@ describe('Cross-Module: Combat & Meditation', () => {
|
||||
useManaStore.setState({ rawMana: 9999 });
|
||||
useCombatStore.setState({
|
||||
currentAction: 'climb',
|
||||
climbDirection: 'up',
|
||||
currentRoomIndex: 0,
|
||||
roomsPerFloor: 1,
|
||||
currentFloor: 1,
|
||||
floorHP: getFloorMaxHP(1),
|
||||
floorMaxHP: getFloorMaxHP(1),
|
||||
|
||||
@@ -44,6 +44,14 @@ export function resetAllStores() {
|
||||
clearedFloors: {},
|
||||
climbDirection: null,
|
||||
isDescending: false,
|
||||
startFloor: 1,
|
||||
exitFloor: 1,
|
||||
currentRoomIndex: 0,
|
||||
roomsPerFloor: 5,
|
||||
descentPeak: null,
|
||||
roomResetState: {},
|
||||
clearedRooms: {},
|
||||
isDescentComplete: false,
|
||||
golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 },
|
||||
equipmentSpellStates: [],
|
||||
comboHitCount: 0,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -20,15 +20,33 @@ export const SPIRE_CONFIG = {
|
||||
treasureRoomChance: 0.3,
|
||||
};
|
||||
|
||||
// ─── Room Count ───────────────────────────────────────────────────────────────
|
||||
// ─── Seeded PRNG ──────────────────────────────────────────────────────────────
|
||||
// Simple mulberry32-style seeded RNG for deterministic room counts & types.
|
||||
|
||||
function makeSeededRandom(seed: number): () => number {
|
||||
let s = seed | 0;
|
||||
return () => {
|
||||
s = (s + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Room Count (spec §4.2) ───────────────────────────────────────────────────
|
||||
// Deterministic per floor via seed = floor × 12345 + runId.
|
||||
// Guardian floors always return 1.
|
||||
|
||||
export function getRoomsForFloor(floor: number, seed?: number): number {
|
||||
if (isGuardianFloor(floor)) return 1;
|
||||
const base = 5;
|
||||
const floorBonus = Math.min(10, Math.floor(floor / 20));
|
||||
|
||||
// Use seeded random if a seed is provided, otherwise fall back to Math.random
|
||||
const randomVariation = seed !== undefined
|
||||
? Math.floor(makeSeededRandom(seed)() * 3)
|
||||
: Math.floor(Math.random() * 3);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -36,46 +54,41 @@ export function getRoomsForFloor(floor: number): number {
|
||||
|
||||
export type SpireRoomType = RoomType | 'recovery' | 'library' | 'treasure';
|
||||
|
||||
// ─── Room Generation ──────────────────────────────────────────────────────────
|
||||
// ─── Room Type Generation (spec §4.3) ─────────────────────────────────────────
|
||||
|
||||
export function generateSpireRoomType(floor: number, roomIndex: number, totalRooms: number): SpireRoomType {
|
||||
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);
|
||||
// Override: every 7th floor, one room (chosen by seed) is always 'puzzle'
|
||||
if (floor % 7 === 0) {
|
||||
const puzzleIndex = Math.floor(makeSeededRandom(floor * 12345 + 7)() * totalRooms);
|
||||
if (roomIndex === puzzleIndex) {
|
||||
return 'puzzle';
|
||||
}
|
||||
}
|
||||
|
||||
// Rare rooms (mid-floor)
|
||||
if (roomIndex === Math.floor(totalRooms / 2) && Math.random() < SPIRE_CONFIG.rareRoomChance) {
|
||||
return generateRareRoomType();
|
||||
// Base roll
|
||||
const roll = makeSeededRandom(floor * 1000 + roomIndex)();
|
||||
|
||||
if (roll < 0.10) {
|
||||
// Rare roll — secondary roll determines sub-type
|
||||
const rareRoll = makeSeededRandom(floor * 1000 + roomIndex + 9999)();
|
||||
if (rareRoll < 0.40) return 'recovery';
|
||||
if (rareRoll < 0.70) return 'treasure';
|
||||
return 'library';
|
||||
}
|
||||
|
||||
// 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';
|
||||
if (roll < 0.22) return 'swarm';
|
||||
if (roll < 0.32) 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 {
|
||||
@@ -113,7 +126,10 @@ export function generateSpireFloorState(floor: number, roomIndex: number, totalR
|
||||
|
||||
case 'puzzle': {
|
||||
const puzzleKeys = Object.keys(PUZZLE_ROOMS);
|
||||
const selectedPuzzle = puzzleKeys[Math.floor(Math.random() * puzzleKeys.length)];
|
||||
const puzzleIdx = Math.floor(
|
||||
makeSeededRandom(floor * 1000 + roomIndex + 5000)() * puzzleKeys.length,
|
||||
);
|
||||
const selectedPuzzle = puzzleKeys[puzzleIdx];
|
||||
const puzzle = PUZZLE_ROOMS[selectedPuzzle];
|
||||
return {
|
||||
roomType: 'puzzle',
|
||||
@@ -173,7 +189,8 @@ function generateCombatRoom(floor: number, element: string, baseHP: number): Flo
|
||||
}
|
||||
|
||||
function generateSwarmRoom(floor: number, element: string, baseHP: number): FloorState {
|
||||
const numEnemies = 3 + Math.floor(Math.random() * 5); // 3-7 enemies
|
||||
const rng = makeSeededRandom(floor * 7777);
|
||||
const numEnemies = 3 + Math.floor(rng() * 5); // 3-7 enemies
|
||||
const enemies: EnemyState[] = [];
|
||||
|
||||
for (let i = 0; i < numEnemies; i++) {
|
||||
|
||||
Reference in New Issue
Block a user