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:
@@ -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