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
+1
View File
@@ -336,6 +336,7 @@ Mana-Loop/
│ │ │ │ │ └── pact-ritual.ts │ │ │ │ │ └── pact-ritual.ts
│ │ │ │ ├── attunementStore.ts │ │ │ │ ├── attunementStore.ts
│ │ │ │ ├── combat-actions.ts │ │ │ │ ├── combat-actions.ts
│ │ │ │ ├── combat-descent-actions.ts
│ │ │ │ ├── combat-state.types.ts │ │ │ │ ├── combat-state.types.ts
│ │ │ │ ├── combatStore.ts │ │ │ │ ├── combatStore.ts
│ │ │ │ ├── crafting-equipment-tick.ts │ │ │ │ ├── crafting-equipment-tick.ts
@@ -12,6 +12,13 @@ import { DebugName } from '@/components/game/debug/debug-context';
interface RoomDisplayProps { interface RoomDisplayProps {
floorState: FloorState; floorState: FloorState;
floor: number; floor: number;
/** 0-indexed current room (spec §3) */
roomIndex?: number;
/** Total rooms on this floor (spec §3) */
totalRooms?: number;
/** Current in-game time for display (combat spec §10) */
day?: number;
hour?: number;
} }
function EnemyRow({ enemy }: { enemy: EnemyState }) { function EnemyRow({ enemy }: { enemy: EnemyState }) {
@@ -72,7 +79,7 @@ function EnemyRow({ enemy }: { enemy: EnemyState }) {
); );
} }
export function RoomDisplay({ floorState }: RoomDisplayProps) { export function RoomDisplay({ floorState, floor, roomIndex, totalRooms, day, hour }: RoomDisplayProps) {
// Guard against null/undefined/stale floorState // Guard against null/undefined/stale floorState
if (!floorState || !floorState.roomType) { if (!floorState || !floorState.roomType) {
return ( return (
@@ -86,6 +93,17 @@ export function RoomDisplay({ floorState }: RoomDisplayProps) {
const roomDisplay = getSpireRoomTypeDisplay(floorState.roomType as RoomType); const roomDisplay = getSpireRoomTypeDisplay(floorState.roomType as RoomType);
// ─── Spec §3: Show "Room X / Y" ─────────────────────────────────────────
const showRoomIndex = roomIndex !== undefined && totalRooms !== undefined;
const roomLabel = showRoomIndex
? `Room ${roomIndex + 1} / ${totalRooms}`
: null;
// ─── Combat spec §10: In-game time display ───────────────────────────────
const timeLabel = day !== undefined && hour !== undefined
? `D${day} H${Math.floor(hour)}`
: null;
// Handle special room types (cast to string for extended types) // Handle special room types (cast to string for extended types)
const rt = floorState.roomType as string; const rt = floorState.roomType as string;
@@ -97,6 +115,8 @@ export function RoomDisplay({ floorState }: RoomDisplayProps) {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#10B981' }}> <CardTitle className="text-sm flex items-center gap-2" style={{ color: '#10B981' }}>
💚 Recovery Room 💚 Recovery Room
{roomLabel && <Badge variant="outline" className="text-[10px] border-green-600 text-green-400">{roomLabel}</Badge>}
{timeLabel && <span className="text-[10px] text-gray-500 ml-auto">{timeLabel}</span>}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -119,6 +139,8 @@ export function RoomDisplay({ floorState }: RoomDisplayProps) {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#6366F1' }}> <CardTitle className="text-sm flex items-center gap-2" style={{ color: '#6366F1' }}>
📚 Ancient Library 📚 Ancient Library
{roomLabel && <Badge variant="outline" className="text-[10px] border-indigo-600 text-indigo-400">{roomLabel}</Badge>}
{timeLabel && <span className="text-[10px] text-gray-500 ml-auto">{timeLabel}</span>}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -136,6 +158,8 @@ export function RoomDisplay({ floorState }: RoomDisplayProps) {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#F59E0B' }}> <CardTitle className="text-sm flex items-center gap-2" style={{ color: '#F59E0B' }}>
💎 Treasure Room 💎 Treasure Room
{roomLabel && <Badge variant="outline" className="text-[10px] border-amber-600 text-amber-400">{roomLabel}</Badge>}
{timeLabel && <span className="text-[10px] text-gray-500 ml-auto">{timeLabel}</span>}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -156,6 +180,8 @@ export function RoomDisplay({ floorState }: RoomDisplayProps) {
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#8B5CF6' }}> <CardTitle className="text-sm flex items-center gap-2" style={{ color: '#8B5CF6' }}>
🧩 Puzzle Room {puzzleId.replace(/_/g, ' ')} 🧩 Puzzle Room {puzzleId.replace(/_/g, ' ')}
{roomLabel && <Badge variant="outline" className="text-[10px] border-purple-600 text-purple-400">{roomLabel}</Badge>}
{timeLabel && <span className="text-[10px] text-gray-500 ml-auto">{timeLabel}</span>}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -186,6 +212,16 @@ export function RoomDisplay({ floorState }: RoomDisplayProps) {
<CardTitle className="text-sm flex items-center gap-2" style={{ color: roomDisplay.color }}> <CardTitle className="text-sm flex items-center gap-2" style={{ color: roomDisplay.color }}>
{roomDisplay.icon} {roomDisplay.label} {roomDisplay.icon} {roomDisplay.label}
{isGuardian && <Badge className="bg-red-900/50 text-red-300 text-xs">BOSS</Badge>} {isGuardian && <Badge className="bg-red-900/50 text-red-300 text-xs">BOSS</Badge>}
{/* ── Spec §3: Room type badge + room index ── */}
{roomLabel && (
<Badge variant="outline" className="text-[10px] border-gray-600 text-gray-400">
{roomLabel}
</Badge>
)}
{/* ── Combat spec §10: In-game time ── */}
{timeLabel && (
<span className="text-[10px] text-gray-500 ml-auto">{timeLabel}</span>
)}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
@@ -2,7 +2,7 @@
import { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useMemo, useRef } from 'react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useCombatStore, useManaStore, usePrestigeStore, fmt, computeMaxMana, computeRegen } from '@/lib/game/stores'; import { useCombatStore, useManaStore, usePrestigeStore, useGameStore, fmt, computeMaxMana, computeRegen } from '@/lib/game/stores';
import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects'; import { computeDisciplineEffects } from '@/lib/game/effects/discipline-effects';
import { getUnifiedEffects } from '@/lib/game/effects'; import { getUnifiedEffects } from '@/lib/game/effects';
import { useCraftingStore } from '@/lib/game/stores/craftingStore'; import { useCraftingStore } from '@/lib/game/stores/craftingStore';
@@ -56,6 +56,7 @@ function useSpireStats(prestigeUpgrades: Record<string, number>, equippedInstanc
export function SpireCombatPage() { export function SpireCombatPage() {
const [roomsCleared, setRoomsCleared] = useState(0); const [roomsCleared, setRoomsCleared] = useState(0);
// ─── Spec: read room-aware state from combat store ───────────────────────
const { const {
currentFloor, currentFloor,
floorHP, floorHP,
@@ -64,6 +65,11 @@ export function SpireCombatPage() {
isDescending, isDescending,
currentRoom, currentRoom,
activityLog, activityLog,
climbDirection,
currentRoomIndex,
roomsPerFloor,
isDescentComplete,
startFloor,
setCurrentRoom, setCurrentRoom,
setFloorHP, setFloorHP,
setClearedFloor, setClearedFloor,
@@ -81,6 +87,11 @@ export function SpireCombatPage() {
isDescending: s.isDescending, isDescending: s.isDescending,
currentRoom: s.currentRoom, currentRoom: s.currentRoom,
activityLog: s.activityLog, activityLog: s.activityLog,
climbDirection: s.climbDirection,
currentRoomIndex: s.currentRoomIndex,
roomsPerFloor: s.roomsPerFloor,
isDescentComplete: s.isDescentComplete,
startFloor: s.startFloor,
setCurrentRoom: s.setCurrentRoom, setCurrentRoom: s.setCurrentRoom,
setFloorHP: s.setFloorHP, setFloorHP: s.setFloorHP,
setClearedFloor: s.setClearedFloor, setClearedFloor: s.setClearedFloor,
@@ -107,6 +118,10 @@ export function SpireCombatPage() {
equipmentInstances: s.equipmentInstances, equipmentInstances: s.equipmentInstances,
}))); })));
// ─── Combat spec §10: read current in-game time ──────────────────────────
const day = useGameStore((s) => s.day);
const hour = useGameStore((s) => s.hour);
const { maxMana, baseRegen } = useSpireStats(prestigeUpgrades, equippedInstances, equipmentInstances); const { maxMana, baseRegen } = useSpireStats(prestigeUpgrades, equippedInstances, equipmentInstances);
// Use a deterministic seed based on floor to avoid Math.random() causing // Use a deterministic seed based on floor to avoid Math.random() causing
@@ -129,8 +144,6 @@ export function SpireCombatPage() {
// Generate initial room when floor or room count changes. // Generate initial room when floor or room count changes.
// Uses a ref guard to prevent infinite re-render loops. // Uses a ref guard to prevent infinite re-render loops.
// Generate room on mount and when floor changes.
// Uses a ref guard to prevent infinite re-render loops.
const lastGeneratedRef = useRef<string | null>(null); const lastGeneratedRef = useRef<string | null>(null);
useEffect(() => { useEffect(() => {
const key = `${currentFloor}:${totalRooms}`; const key = `${currentFloor}:${totalRooms}`;
@@ -236,7 +249,14 @@ export function SpireCombatPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div className="lg:col-span-2"> <div className="lg:col-span-2">
<RoomDisplay floorState={currentRoom} floor={currentFloor} /> <RoomDisplay
floorState={currentRoom}
floor={currentFloor}
roomIndex={currentRoomIndex}
totalRooms={roomsPerFloor}
day={day}
hour={hour}
/>
</div> </div>
<div> <div>
<SpireCombatControls castProgress={castProgress} /> <SpireCombatControls castProgress={castProgress} />
@@ -5,7 +5,7 @@ import { useShallow } from 'zustand/react/shallow';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Mountain, ArrowUp, ArrowDown, LogOut } from 'lucide-react'; import { Mountain, ArrowUp, ArrowDown, LogOut, ChevronDown } from 'lucide-react';
import { getGuardianForFloor, isGuardianFloor } from '@/lib/game/data/guardian-encounters'; import { getGuardianForFloor, isGuardianFloor } from '@/lib/game/data/guardian-encounters';
import { DebugName } from '@/components/game/debug/debug-context'; import { DebugName } from '@/components/game/debug/debug-context';
@@ -34,11 +34,16 @@ export function SpireHeader({
}: SpireHeaderProps) { }: SpireHeaderProps) {
const maxFloorReached = useCombatStore((s) => s.maxFloorReached); const maxFloorReached = useCombatStore((s) => s.maxFloorReached);
const insight = usePrestigeStore((s) => s.insight); const insight = usePrestigeStore((s) => s.insight);
// ─── Spec: read climbDirection and isDescentComplete ─────────────────────
const climbDirection = useCombatStore((s) => s.climbDirection);
const isDescentComplete = useCombatStore((s) => s.isDescentComplete);
const enterDescentMode = useCombatStore((s) => s.enterDescentMode);
const guardian = getGuardianForFloor(currentFloor); const guardian = getGuardianForFloor(currentFloor);
const isGuardian = isGuardianFloor(currentFloor); const isGuardian = isGuardianFloor(currentFloor);
const hpPercent = floorMaxHP > 0 ? (floorHP / floorMaxHP) * 100 : 100; const hpPercent = floorMaxHP > 0 ? (floorHP / floorMaxHP) * 100 : 100;
const roomProgress = totalRooms > 0 ? ((roomsCleared) / totalRooms) * 100 : 0; const roomProgress = totalRooms > 0 ? ((roomsCleared) / totalRooms) * 100 : 0;
const isAscending = climbDirection === 'up';
return ( return (
<DebugName name="SpireHeader"> <DebugName name="SpireHeader">
@@ -59,6 +64,22 @@ export function SpireHeader({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* ── Spec §4.5: "Descend" button available at any point during ascent ── */}
{isAscending && (
<Button
size="sm"
variant="outline"
onClick={enterDescentMode}
className="border-amber-600 text-amber-400 hover:bg-amber-900/30"
>
<ChevronDown className="w-4 h-4 mr-1" />
Descend
</Button>
)}
{/* Legacy climb buttons (kept for non-spire-mode compatibility) */}
{!climbDirection && (
<>
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -79,7 +100,11 @@ export function SpireHeader({
<ArrowDown className="w-4 h-4 mr-1" /> <ArrowDown className="w-4 h-4 mr-1" />
Climb Down Climb Down
</Button> </Button>
{currentFloor === 1 && ( </>
)}
{/* ── Spec §4.9: "Exit Spire" only visible when isDescentComplete ── */}
{isDescentComplete && (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
+24 -4
View File
@@ -26,8 +26,16 @@ function resetStores() {
spireMode: false, spireMode: false,
currentRoom: { roomType: 'combat', enemies: [] }, currentRoom: { roomType: 'combat', enemies: [] },
clearedFloors: {}, clearedFloors: {},
climbDirection: null, climbDirection: 'up',
isDescending: false, isDescending: false,
startFloor: 1,
exitFloor: 1,
currentRoomIndex: 0,
roomsPerFloor: 5,
descentPeak: null,
roomResetState: {},
clearedRooms: {},
isDescentComplete: false,
golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 }, golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 },
equipmentSpellStates: [], equipmentSpellStates: [],
comboHitCount: 0, comboHitCount: 0,
@@ -103,13 +111,25 @@ describe('processCombatTick', () => {
}); });
describe('floor advancement', () => { 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 // 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 elements = makeInitialElements(500, {});
const result = runCombatTick(1000, elements); const result = runCombatTick(1000, elements);
expect(result.currentFloor).toBe(2); expect(result.currentFloor).toBe(2);
expect(result.floorHP).toBe(getFloorMaxHP(2)); expect(result.floorHP).toBeGreaterThan(0);
}); });
it('should update maxFloorReached when advancing', () => { it('should update maxFloorReached when advancing', () => {
@@ -14,6 +14,9 @@ describe('Cross-Module: Combat & Meditation', () => {
useManaStore.setState({ rawMana: 9999 }); useManaStore.setState({ rawMana: 9999 });
useCombatStore.setState({ useCombatStore.setState({
currentAction: 'climb', currentAction: 'climb',
climbDirection: 'up',
currentRoomIndex: 0,
roomsPerFloor: 1,
currentFloor: 1, currentFloor: 1,
floorHP: getFloorMaxHP(1), floorHP: getFloorMaxHP(1),
floorMaxHP: getFloorMaxHP(1), floorMaxHP: getFloorMaxHP(1),
@@ -45,6 +48,9 @@ describe('Cross-Module: Combat & Meditation', () => {
it('should track maxFloorReached across multiple floors', () => { it('should track maxFloorReached across multiple floors', () => {
useCombatStore.setState({ useCombatStore.setState({
currentAction: 'climb', currentAction: 'climb',
climbDirection: 'up',
currentRoomIndex: 0,
roomsPerFloor: 1,
currentFloor: 1, currentFloor: 1,
floorHP: getFloorMaxHP(1), floorHP: getFloorMaxHP(1),
floorMaxHP: getFloorMaxHP(1), floorMaxHP: getFloorMaxHP(1),
@@ -61,6 +67,9 @@ describe('Cross-Module: Combat & Meditation', () => {
it('should cap maxFloorReached at 100', () => { it('should cap maxFloorReached at 100', () => {
useCombatStore.setState({ useCombatStore.setState({
currentAction: 'climb', currentAction: 'climb',
climbDirection: 'up',
currentRoomIndex: 0,
roomsPerFloor: 1,
currentFloor: 100, currentFloor: 100,
floorHP: getFloorMaxHP(100), floorHP: getFloorMaxHP(100),
floorMaxHP: getFloorMaxHP(100), floorMaxHP: getFloorMaxHP(100),
@@ -78,6 +87,9 @@ describe('Cross-Module: Combat & Meditation', () => {
useManaStore.setState({ rawMana: 9999 }); useManaStore.setState({ rawMana: 9999 });
useCombatStore.setState({ useCombatStore.setState({
currentAction: 'climb', currentAction: 'climb',
climbDirection: 'up',
currentRoomIndex: 0,
roomsPerFloor: 1,
currentFloor: 1, currentFloor: 1,
floorHP: getFloorMaxHP(1), floorHP: getFloorMaxHP(1),
floorMaxHP: getFloorMaxHP(1), floorMaxHP: getFloorMaxHP(1),
@@ -44,6 +44,14 @@ export function resetAllStores() {
clearedFloors: {}, clearedFloors: {},
climbDirection: null, climbDirection: null,
isDescending: false, isDescending: false,
startFloor: 1,
exitFloor: 1,
currentRoomIndex: 0,
roomsPerFloor: 5,
descentPeak: null,
roomResetState: {},
clearedRooms: {},
isDescentComplete: false,
golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 }, golemancy: { enabledGolems: [], summonedGolems: [], lastSummonFloor: 0 },
equipmentSpellStates: [], equipmentSpellStates: [],
comboHitCount: 0, comboHitCount: 0,
+90 -20
View File
@@ -14,12 +14,12 @@ import { isGuardianFloor, getGuardianForFloor, getGuardianHP, generateGuardianNa
// ─── Spire Utils ───────────────────────────────────────────────────────────── // ─── Spire Utils ─────────────────────────────────────────────────────────────
describe('getRoomsForFloor', () => { 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++) { for (let floor = 1; floor <= 50; floor++) {
if (floor % 10 === 0) continue; if (floor % 10 === 0) continue;
const rooms = getRoomsForFloor(floor); const rooms = getRoomsForFloor(floor, floor * 12345);
expect(rooms).toBeGreaterThanOrEqual(SPIRE_CONFIG.minRoomsPerFloor); 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); expect(getRoomsForFloor(100)).toBe(1);
}); });
it('should return more rooms for higher non-guardian floors', () => { it('should return deterministic results with same seed', () => {
const lowFloor = getRoomsForFloor(3); for (let floor = 1; floor <= 50; floor++) {
const highFloor = getRoomsForFloor(79); 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); expect(highFloor).toBeGreaterThanOrEqual(lowFloor);
}); });
}); });
@@ -43,27 +53,38 @@ describe('generateSpireRoomType', () => {
expect(roomType).toBe('guardian'); 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]) { for (const floor of [1, 5, 15, 25]) {
const roomType = generateSpireRoomType(floor, 0, 10); for (let ri = 0; ri < 10; ri++) {
expect(['combat', 'swarm', 'speed']).toContain(roomType); 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)', () => { it('should never return guardian for non-last room on guardian floors', () => {
// First room on non-last position should never be 'guardian'
for (let i = 0; i < 50; i++) { for (let i = 0; i < 50; i++) {
const roomType = generateSpireRoomType(50, 0, 10); const roomType = generateSpireRoomType(50, 0, 10);
expect(['combat', 'swarm', 'speed']).toContain(roomType);
expect(roomType).not.toBe('guardian'); expect(roomType).not.toBe('guardian');
} }
}); });
it('should return valid room types', () => { it('should return puzzle on every 7th floor for exactly one room', () => {
const validTypes = ['combat', 'swarm', 'speed', 'guardian', 'puzzle', 'recovery', 'library', 'treasure']; // Floor 7 should have exactly one puzzle room
for (let i = 0; i < 100; i++) { let puzzleCount = 0;
const roomType = generateSpireRoomType(25, 3, 10); for (let ri = 0; ri < 17; ri++) {
expect(validTypes).toContain(roomType); 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(); 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); const state = generateSpireFloorState(5, 0, 8);
expect(state.enemies.length).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].hp).toBeGreaterThan(0);
expect(state.enemies[0].maxHP).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', () => { it('should generate swarm floor with multiple enemies', () => {
const state = generateSpireFloorState(20, 1, 10); // 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') { if (state.roomType === 'swarm') {
expect(state.enemies.length).toBeGreaterThanOrEqual(3); 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);
}); });
}); });
+22 -9
View File
@@ -128,19 +128,27 @@ export function processCombatTick(
castProgress -= 1; castProgress -= 1;
safetyCounter++; safetyCounter++;
// Check if floor is cleared // Check if room/floor is cleared
if (floorHP <= 0) { if (floorHP <= 0) {
const guardian = getGuardianForFloor(currentFloor); const guardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!guardian); onFloorCleared(currentFloor, !!guardian);
currentFloor = Math.min(currentFloor + 1, 100);
floorMaxHP = getFloorMaxHP(currentFloor); // ── Spec: room-aware advancement (climbing spec §4.4) ──────────────
floorHP = floorMaxHP; // 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; castProgress = 0;
if (guardian) { if (guardian) {
logMessages.push(`\u2694\ufe0f ${guardian.name} defeated!`); logMessages.push(`\u2694\ufe0f ${guardian.name} defeated!`);
} else if (currentFloor % 5 === 0) { } 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) { if (floorHP <= 0) {
const eGuardian = getGuardianForFloor(currentFloor); const eGuardian = getGuardianForFloor(currentFloor);
onFloorCleared(currentFloor, !!eGuardian); onFloorCleared(currentFloor, !!eGuardian);
currentFloor = Math.min(currentFloor + 1, 100);
floorMaxHP = getFloorMaxHP(currentFloor); // ── Spec: room-aware advancement ─────────────────────────────────
floorHP = floorMaxHP; get().advanceRoomOrFloor();
const newState = get();
currentFloor = newState.currentFloor;
floorMaxHP = newState.floorMaxHP;
floorHP = newState.floorHP;
eCastProgress = 0; eCastProgress = 0;
if (eGuardian) { if (eGuardian) {
logMessages.push(`\u2694\ufe0f ${eGuardian.name} defeated!`); logMessages.push(`\u2694\ufe0f ${eGuardian.name} defeated!`);
} else if (currentFloor % 5 === 0) { } else if (currentFloor % 5 === 0) {
logMessages.push(`🏰 Floor ${currentFloor - 1} cleared!`); logMessages.push(`\ud83c\udff0 Floor ${currentFloor - 1} cleared!`);
} }
break; 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; climbDirection: 'up' | 'down' | null;
isDescending: boolean; 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 (summoned golems)
golemancy: GolemancyState; golemancy: GolemancyState;
@@ -85,6 +105,16 @@ export interface CombatActions {
startPracticing: () => void; startPracticing: () => void;
stopPracticing: () => 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 // Golemancy
toggleGolem: (golemId: string) => void; toggleGolem: (golemId: string) => void;
setEnabledGolems: (golemIds: 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 type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType } from '../types';
import { getFloorMaxHP } from '../utils'; import { getFloorMaxHP } from '../utils';
import { generateFloorState } from '../utils/room-utils'; import { generateFloorState } from '../utils/room-utils';
import { generateSpireFloorState } from '../utils/spire-utils';
import { addActivityLogEntry } from '../utils/activity-log'; import { addActivityLogEntry } from '../utils/activity-log';
import { processCombatTick, makeInitialSpells } from './combat-actions'; import { processCombatTick, makeInitialSpells } from './combat-actions';
import { getGuardianForFloor } from '../data/guardian-encounters'; import { getGuardianForFloor } from '../data/guardian-encounters';
import type { CombatStore } from './combat-state.types'; import type { CombatStore } from './combat-state.types';
import {
enterDescentMode,
advanceRoomOrFloor,
onEnterRoomDescend,
onEnterLibraryRoom,
createEnterSpireMode,
} from './combat-descent-actions';
export const useCombatStore = create<CombatStore>()( export const useCombatStore = create<CombatStore>()(
persist( persist(
@@ -33,6 +39,18 @@ export const useCombatStore = create<CombatStore>()(
climbDirection: null, climbDirection: null,
isDescending: false, 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
golemancy: { golemancy: {
enabledGolems: [], enabledGolems: [],
@@ -158,13 +176,18 @@ export const useCombatStore = create<CombatStore>()(
currentAction: 'meditate', currentAction: 'meditate',
climbDirection: null, climbDirection: null,
isDescending: false, isDescending: false,
currentFloor: 1, currentFloor: s.exitFloor,
floorHP: getFloorMaxHP(1), floorHP: getFloorMaxHP(s.exitFloor),
floorMaxHP: getFloorMaxHP(1), floorMaxHP: getFloorMaxHP(s.exitFloor),
currentRoom: generateFloorState(1), currentRoom: generateFloorState(s.exitFloor),
castProgress: 0, castProgress: 0,
clearedFloors: {}, 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), maxFloorReached: Math.max(s.maxFloorReached, 1),
})); }));
}, },
@@ -174,17 +197,21 @@ export const useCombatStore = create<CombatStore>()(
startClimbDown: () => set({ climbDirection: 'down', currentAction: 'climb' }), startClimbDown: () => set({ climbDirection: 'down', currentAction: 'climb' }),
startPracticing: () => set((s) => { startPracticing: () => set((s) => {
// Only override if the current action is 'meditate' — don't clobber climb/study/etc.
if (s.currentAction !== 'meditate') return s; if (s.currentAction !== 'meditate') return s;
return { currentAction: 'practicing' }; return { currentAction: 'practicing' };
}), }),
stopPracticing: () => set((s) => { stopPracticing: () => set((s) => {
// Only restore to meditate if we're currently practicing (don't clobber other actions)
if (s.currentAction !== 'practicing') return s; if (s.currentAction !== 'practicing') return s;
return { currentAction: 'meditate' }; 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 // Golemancy
toggleGolem: (golemId: string) => { toggleGolem: (golemId: string) => {
set((s) => { set((s) => {
@@ -207,22 +234,7 @@ export const useCombatStore = create<CombatStore>()(
})); }));
}, },
enterSpireMode: () => { enterSpireMode: createEnterSpireMode(get, set, generateFloorState),
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)
}));
},
learnSpell: (spellId: string) => { learnSpell: (spellId: string) => {
set((state) => ({ set((state) => ({
+54 -37
View File
@@ -20,15 +20,33 @@ export const SPIRE_CONFIG = {
treasureRoomChance: 0.3, 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; return base + floorBonus + randomVariation;
} }
@@ -36,46 +54,41 @@ export function getRoomsForFloor(floor: number): number {
export type SpireRoomType = RoomType | 'recovery' | 'library' | 'treasure'; 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 // Last room on guardian floors is always guardian
if (isGuardianFloor(floor) && roomIndex === totalRooms - 1) { if (isGuardianFloor(floor) && roomIndex === totalRooms - 1) {
return 'guardian'; return 'guardian';
} }
// First room on a floor is never a special room (always combat) // Override: every 7th floor, one room (chosen by seed) is always 'puzzle'
if (roomIndex === 0) { if (floor % 7 === 0) {
return generateCombatRoomType(floor); const puzzleIndex = Math.floor(makeSeededRandom(floor * 12345 + 7)() * totalRooms);
} if (roomIndex === puzzleIndex) {
// Rare rooms (mid-floor)
if (roomIndex === Math.floor(totalRooms / 2) && Math.random() < SPIRE_CONFIG.rareRoomChance) {
return generateRareRoomType();
}
// Puzzle rooms
if (floor % 7 === 0 && Math.random() < SPIRE_CONFIG.puzzleRoomChance) {
return 'puzzle'; return 'puzzle';
} }
}
return generateCombatRoomType(floor); // Base roll
} const roll = makeSeededRandom(floor * 1000 + roomIndex)();
function generateCombatRoomType(_floor: number): RoomType { if (roll < 0.10) {
const roll = Math.random(); // Rare roll — secondary roll determines sub-type
if (roll < 0.12) return 'swarm'; const rareRoll = makeSeededRandom(floor * 1000 + roomIndex + 9999)();
if (roll < 0.22) return 'speed'; if (rareRoll < 0.40) return 'recovery';
if (rareRoll < 0.70) return 'treasure';
return 'library';
}
if (roll < 0.22) return 'swarm';
if (roll < 0.32) return 'speed';
return 'combat'; 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 ─────────────────────────────────────────────────── // ─── Floor State Generation ───────────────────────────────────────────────────
export function generateSpireFloorState(floor: number, roomIndex: number, totalRooms: number): FloorState { export function generateSpireFloorState(floor: number, roomIndex: number, totalRooms: number): FloorState {
@@ -113,7 +126,10 @@ export function generateSpireFloorState(floor: number, roomIndex: number, totalR
case 'puzzle': { case 'puzzle': {
const puzzleKeys = Object.keys(PUZZLE_ROOMS); 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]; const puzzle = PUZZLE_ROOMS[selectedPuzzle];
return { return {
roomType: 'puzzle', roomType: 'puzzle',
@@ -173,7 +189,8 @@ function generateCombatRoom(floor: number, element: string, baseHP: number): Flo
} }
function generateSwarmRoom(floor: number, element: string, baseHP: number): FloorState { 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[] = []; const enemies: EnemyState[] = [];
for (let i = 0; i < numEnemies; i++) { for (let i = 0; i < numEnemies; i++) {