-
- Solve the puzzle. Higher attunement levels speed up progress.
-
-
-
- Progress: {Math.round(progress * 100)} / {Math.round(required * 100)}
+
+ {thematicText}. Higher attunement levels speed up progress.
+
+
+ {Math.round(progress * 100) / 100} / {Math.round(required * 100) / 100} hours
+ {attunements.length > 0 && (
+
+ Relevant attunements: {attunements.join(', ')}
+
+ )}
);
}
- // Combat rooms (combat, swarm, speed, guardian)
+ // ─── Combat rooms (combat, swarm, speed, guardian) ───────────────────────
const enemies = floorState.enemies || [];
const isGuardian = floorState.roomType === 'guardian';
@@ -212,13 +284,11 @@ export function RoomDisplay({ floorState, floor, roomIndex, totalRooms, day, hou
{roomDisplay.icon} {roomDisplay.label}
{isGuardian && BOSS}
- {/* ── Spec §3: Room type badge + room index ── */}
{roomLabel && (
{roomLabel}
)}
- {/* ── Combat spec §10: In-game time ── */}
{timeLabel && (
{timeLabel}
)}
diff --git a/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx b/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx
index 19a5fe0..40d41d8 100644
--- a/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx
+++ b/src/components/game/tabs/SpireCombatPage/SpireCombatPage.tsx
@@ -70,6 +70,8 @@ export function SpireCombatPage() {
startClimbDown,
addActivityLog,
setAction,
+ skipNonCombatRoom,
+ stayLongerInRoom,
} = useCombatStore(useShallow((s) => ({
currentFloor: s.currentFloor,
floorHP: s.floorHP,
@@ -88,6 +90,8 @@ export function SpireCombatPage() {
startClimbDown: s.startClimbDown,
addActivityLog: s.addActivityLog,
setAction: s.setAction,
+ skipNonCombatRoom: s.skipNonCombatRoom,
+ stayLongerInRoom: s.stayLongerInRoom,
})));
const { rawMana, elements } = useManaStore(useShallow((s) => ({
@@ -168,6 +172,8 @@ export function SpireCombatPage() {
totalRooms={roomsPerFloor}
day={day}
hour={hour}
+ onSkip={skipNonCombatRoom}
+ onStayLonger={stayLongerInRoom}
/>
diff --git a/src/lib/game/stores/combat-descent-actions.ts b/src/lib/game/stores/combat-descent-actions.ts
index aec9863..44df09e 100644
--- a/src/lib/game/stores/combat-descent-actions.ts
+++ b/src/lib/game/stores/combat-descent-actions.ts
@@ -6,9 +6,14 @@ 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 { useManaStore } from './manaStore';
import { summonGolemsOnRoomEntry } from './golem-combat-actions';
+import {
+ onEnterLibraryRoom,
+ onEnterRecoveryRoom,
+ onEnterTreasureRoom,
+ onEnterPuzzleRoom,
+} from './non-combat-room-actions';
type GetFn = () => CombatStore;
@@ -31,7 +36,6 @@ export function enterDescentMode(get: GetFn, set: SetFn): void {
});
get().addActivityLog('floor_transition',
`Beginning descent from Floor ${s.currentFloor}, Room ${s.currentRoomIndex + 1}`);
- // Start descending from the current room
onEnterRoomDescend(get, set);
}
@@ -41,7 +45,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) {
@@ -80,12 +83,9 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
});
}
- // ── Golem summoning on room entry (spec §9.3) ─────────────────────
summonGolemsForRoom(get, set);
-
onEnterRoomDescend(get, set);
} else {
- // ── Ascending (spec §4.4) ─────────────────────────────────────────────
const roomKey = `${s.currentFloor}:${s.currentRoomIndex}`;
set((prev) => ({
clearedRooms: { ...prev.clearedRooms, [roomKey]: true },
@@ -121,15 +121,18 @@ export function advanceRoomOrFloor(get: GetFn, set: SetFn): void {
});
}
- // ── Golem summoning on room entry (spec §9.3) ─────────────────────
summonGolemsForRoom(get, set);
- // Handle non-combat rooms on ascent
+ // Handle non-combat rooms on ascent — initialize progress, do NOT auto-advance
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);
+ } else if (room.roomType === 'recovery') {
+ onEnterRecoveryRoom(get, set);
+ } else if (room.roomType === 'treasure') {
+ onEnterTreasureRoom(get, set);
+ } else if (room.roomType === 'puzzle') {
+ onEnterPuzzleRoom(get, set);
}
}
}
@@ -155,12 +158,10 @@ function summonGolemsForRoom(get: GetFn, set: SetFn): void {
summonResult.logMessages.forEach((msg) => get().addActivityLog('special_effect', msg));
}
- // Write summoned golems back to combat store
set({
golemancy: { ...s.golemancy, activeGolems: summonResult.activeGolems },
});
- // Deduct summon costs from mana store
useManaStore.setState({
rawMana: summonResult.rawMana,
elements: summonResult.elements,
@@ -213,44 +214,6 @@ export function onEnterRoomDescend(get: GetFn, set: SetFn): void {
}
}
-// ─── 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) {
diff --git a/src/lib/game/stores/combat-state.types.ts b/src/lib/game/stores/combat-state.types.ts
index e8edfad..045f3ec 100644
--- a/src/lib/game/stores/combat-state.types.ts
+++ b/src/lib/game/stores/combat-state.types.ts
@@ -117,6 +117,12 @@ export interface CombatActions {
onEnterRoomDescend: () => void;
/** Grant discipline XP scaled by floor, then advance */
onEnterLibraryRoom: () => void;
+ /** Tick non-combat room progress (called from game tick pipeline) */
+ tickNonCombatRoom: (hours: number) => void;
+ /** Skip current non-combat room (library, recovery, treasure) */
+ skipNonCombatRoom: () => void;
+ /** Stay 1 hour longer in library or recovery room */
+ stayLongerInRoom: () => void;
// Golemancy
toggleGolem: (golemId: string) => void;
diff --git a/src/lib/game/stores/combatStore.ts b/src/lib/game/stores/combatStore.ts
index 6a795fb..e318d80 100644
--- a/src/lib/game/stores/combatStore.ts
+++ b/src/lib/game/stores/combatStore.ts
@@ -12,12 +12,11 @@ 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,
+ enterDescentMode, advanceRoomOrFloor, onEnterRoomDescend, createEnterSpireMode,
} from './combat-descent-actions';
+import {
+ onEnterLibraryRoom, tickNonCombatRoom, skipNonCombatRoom, stayLongerInRoom,
+} from './non-combat-room-actions';
export const useCombatStore = create()(
persist(
@@ -221,6 +220,9 @@ export const useCombatStore = create()(
advanceRoomOrFloor: () => advanceRoomOrFloor(get, set),
onEnterRoomDescend: () => onEnterRoomDescend(get, set),
onEnterLibraryRoom: () => onEnterLibraryRoom(get, set),
+ tickNonCombatRoom: (hours: number) => tickNonCombatRoom(get, set, hours),
+ skipNonCombatRoom: () => skipNonCombatRoom(get, set),
+ stayLongerInRoom: () => stayLongerInRoom(get, set),
// Golemancy
toggleGolem: (golemId: string) => {
diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts
index 0adfd73..95e8b2e 100644
--- a/src/lib/game/stores/gameStore.ts
+++ b/src/lib/game/stores/gameStore.ts
@@ -262,6 +262,26 @@ export const useGameStore = create()(
writes.combat = { ...(writes.combat || {}), currentFloor: cr.currentFloor, floorHP: cr.floorHP, floorMaxHP: cr.floorMaxHP, maxFloorReached: cr.maxFloorReached, castProgress: cr.castProgress, equipmentSpellStates: cr.equipmentSpellStates, golemancy: { ...(writes.combat?.golemancy || ctx.combat.golemancy), activeGolems: cr.activeGolems }, meleeSwordProgress: cr.meleeSwordProgress, currentRoom: cr.currentRoom };
}
+ // Non-combat room tick (library, recovery, treasure, puzzle)
+ if (ctx.combat.currentAction === 'climb') {
+ const roomType = ctx.combat.currentRoom?.roomType;
+ if (roomType === 'library' || roomType === 'recovery' || roomType === 'treasure' || roomType === 'puzzle') {
+ if (roomType === 'recovery') {
+ const boostedRegen = baseRegen * 10;
+ const netBoostedRegen = Math.max(0, boostedRegen * (1 - incursionStrength) * meditationMultiplier - conversionResult.totalRawDrain);
+ rawMana = Math.min(rawMana + netBoostedRegen * HOURS_PER_TICK, maxMana);
+ for (const [elem, entry] of Object.entries(conversionResult.rates)) {
+ if (entry.paused || entry.finalRate <= 0 || !elements[elem]) continue;
+ if (!elements[elem].unlocked) elements[elem] = { ...elements[elem], unlocked: true };
+ elements[elem] = { ...elements[elem], current: Math.min(elements[elem].max, elements[elem].current + entry.finalRate * 10 * HOURS_PER_TICK) };
+ }
+ }
+ useCombatStore.getState().tickNonCombatRoom(HOURS_PER_TICK);
+ const updatedRoom = useCombatStore.getState().currentRoom;
+ writes.combat = { ...(writes.combat || {}), currentRoom: updatedRoom };
+ }
+ }
+
if (ctx.combat.currentAction === 'craft') {
const craftingResult = useCraftingStore.getState().processEquipmentCraftingTick();
if (craftingResult.logMessage) {
diff --git a/src/lib/game/stores/non-combat-room-actions.ts b/src/lib/game/stores/non-combat-room-actions.ts
new file mode 100644
index 0000000..2dfecd5
--- /dev/null
+++ b/src/lib/game/stores/non-combat-room-actions.ts
@@ -0,0 +1,287 @@
+// ─── Non-Combat Room Actions ───────────────────────────────────────────────────
+// Handles timed progression for Library, Recovery, Treasure, and Puzzle rooms.
+// Extracted from combat-descent-actions.ts to stay under the 400-line limit.
+
+import type { CombatState, CombatStore } from './combat-state.types';
+import { useDisciplineStore } from './discipline-slice';
+import { useManaStore } from './manaStore';
+import { useAttunementStore } from './attunementStore';
+import { PUZZLE_ROOMS } from '../constants';
+import { advanceRoomOrFloor } from './combat-descent-actions';
+
+type GetFn = () => CombatStore;
+type SetFn = (state: Partial) => void;
+
+// ─── Room Entry Handlers ──────────────────────────────────────────────────────
+
+export function onEnterLibraryRoom(get: GetFn, set: SetFn): void {
+ const s = get();
+ set({
+ currentRoom: {
+ ...s.currentRoom,
+ libraryProgress: 0,
+ libraryRequired: 1,
+ libraryStayed: false,
+ },
+ });
+ get().addActivityLog('floor_transition',
+ `Entered library room on Floor ${s.currentFloor}`);
+}
+
+export function onEnterRecoveryRoom(get: GetFn, set: SetFn): void {
+ const s = get();
+ set({
+ currentRoom: {
+ ...s.currentRoom,
+ recoveryProgress: 0,
+ recoveryRequired: 1,
+ recoveryStayed: false,
+ },
+ });
+ get().addActivityLog('floor_transition',
+ `Entered recovery room on Floor ${s.currentFloor}`);
+}
+
+export function onEnterTreasureRoom(get: GetFn, set: SetFn): void {
+ const s = get();
+ set({
+ currentRoom: {
+ ...s.currentRoom,
+ treasureProgress: 0,
+ treasureRequired: 1,
+ treasureLootClaimed: [],
+ },
+ });
+ get().addActivityLog('floor_transition',
+ `Entered treasure room on Floor ${s.currentFloor}`);
+}
+
+export function onEnterPuzzleRoom(get: GetFn, set: SetFn): void {
+ const s = get();
+ const puzzleId = s.currentRoom.puzzleId || 'enchanter_trial';
+ const puzzle = PUZZLE_ROOMS[puzzleId];
+ const attunements = puzzle?.attunements ?? ['enchanter'];
+
+ let baseHours: number;
+ if (s.currentFloor <= 20) {
+ baseHours = 4;
+ } else if (s.currentFloor <= 50) {
+ baseHours = 8;
+ } else if (s.currentFloor <= 100) {
+ baseHours = 16;
+ } else {
+ baseHours = 24;
+ }
+
+ const attunementStore = useAttunementStore.getState();
+ const maxReduction = 0.9;
+ const perAttunementShare = maxReduction / attunements.length;
+ let totalReduction = 0;
+
+ for (const attId of attunements) {
+ const attState = attunementStore.attunements[attId];
+ if (attState?.active) {
+ const MAX_LEVEL = 10;
+ const levelFraction = Math.min((attState.level || 1) / MAX_LEVEL, 1);
+ totalReduction += perAttunementShare * levelFraction;
+ }
+ }
+
+ totalReduction = Math.min(totalReduction, maxReduction);
+ const requiredHours = baseHours * (1 - totalReduction);
+
+ set({
+ currentRoom: {
+ ...s.currentRoom,
+ puzzleProgress: 0,
+ puzzleRequired: Math.max(0.5, requiredHours),
+ puzzleId,
+ puzzleAttunements: attunements,
+ },
+ });
+ get().addActivityLog('floor_transition',
+ `Entered puzzle room on Floor ${s.currentFloor} (${puzzle?.name || puzzleId})`);
+}
+
+// ─── Tick Handlers ────────────────────────────────────────────────────────────
+
+export function tickNonCombatRoom(get: GetFn, set: SetFn, hours: number): void {
+ const s = get();
+ const rt = s.currentRoom.roomType as string;
+
+ if (rt === 'library') {
+ tickLibraryRoom(get, set, hours);
+ } else if (rt === 'recovery') {
+ tickRecoveryRoom(get, set, hours);
+ } else if (rt === 'treasure') {
+ tickTreasureRoom(get, set, hours);
+ } else if (rt === 'puzzle') {
+ tickPuzzleRoom(get, set, hours);
+ }
+}
+
+function tickLibraryRoom(get: GetFn, set: SetFn, hours: number): void {
+ const s = get();
+ const room = s.currentRoom;
+ const progress = (room.libraryProgress || 0) + hours;
+ const required = room.libraryRequired || 1;
+
+ const BASE_LIBRARY_XP_PER_HOUR = 50;
+ const xpGrant = Math.floor(BASE_LIBRARY_XP_PER_HOUR * (1 + s.currentFloor / 10) * 25 * hours);
+
+ if (xpGrant > 0) {
+ const disciplineStore = useDisciplineStore.getState();
+ const allDisciplines = disciplineStore.disciplines;
+ const entries = Object.entries(allDisciplines);
+
+ if (entries.length > 0) {
+ const [targetId, targetDs] = entries[Math.floor(Math.random() * entries.length)];
+ useDisciplineStore.setState((prev) => ({
+ disciplines: {
+ ...prev.disciplines,
+ [targetId]: { ...targetDs, xp: (targetDs?.xp || 0) + xpGrant },
+ },
+ totalXP: prev.totalXP + xpGrant,
+ }));
+ get().addActivityLog('special_effect',
+ `${targetDs?.id || targetId} gained ${xpGrant} XP from studying ancient tomes`);
+ }
+ }
+
+ if (progress >= required) {
+ set({ currentRoom: { ...room, libraryProgress: progress } });
+ advanceRoomOrFloor(get, set);
+ } else {
+ set({ currentRoom: { ...room, libraryProgress: progress } });
+ }
+}
+
+function tickRecoveryRoom(get: GetFn, set: SetFn, hours: number): void {
+ const s = get();
+ const room = s.currentRoom;
+ const progress = (room.recoveryProgress || 0) + hours;
+ const required = room.recoveryRequired || 1;
+
+ if (progress >= required) {
+ set({ currentRoom: { ...room, recoveryProgress: progress } });
+ advanceRoomOrFloor(get, set);
+ } else {
+ set({ currentRoom: { ...room, recoveryProgress: progress } });
+ }
+}
+
+function tickTreasureRoom(get: GetFn, set: SetFn, hours: number): void {
+ const s = get();
+ const room = s.currentRoom;
+ const progress = (room.treasureProgress || 0) + hours;
+ const required = room.treasureRequired || 1;
+ const loot = room.treasureLoot || [];
+ const claimed = room.treasureLootClaimed || [];
+
+ const thresholds = [0.10, 0.50, 0.95, 1.0];
+ const newlyClaimed: number[] = [];
+ const claimedSet = new Set(claimed);
+
+ for (const threshold of thresholds) {
+ if (progress / required >= threshold) {
+ const targetCount = Math.min(Math.ceil(loot.length * threshold), loot.length);
+ for (let i = 0; i < targetCount; i++) {
+ if (!claimedSet.has(i)) {
+ claimedSet.add(i);
+ newlyClaimed.push(i);
+ }
+ }
+ }
+ }
+
+ for (const idx of newlyClaimed) {
+ const drop = loot[idx];
+ if (!drop) continue;
+
+ if (drop.type === 'material' || drop.type === 'gold') {
+ const amount = drop.amount
+ ? Math.floor(Math.random() * (drop.amount.max - drop.amount.min + 1) + drop.amount.min)
+ : 1;
+ useManaStore.getState().addRawMana(amount, 999999);
+ get().addActivityLog('special_effect', `Found ${amount}x ${drop.name}`);
+ } else if (drop.type === 'essence') {
+ const elementMap: Record = {
+ fireEssenceDrop: 'fire', waterEssenceDrop: 'water', airEssenceDrop: 'air',
+ earthEssenceDrop: 'earth', lightEssenceDrop: 'light', darkEssenceDrop: 'dark',
+ deathEssenceDrop: 'death',
+ };
+ const elem = elementMap[drop.id];
+ if (elem) {
+ const elemState = useManaStore.getState().elements[elem];
+ if (elemState) {
+ const amount = drop.amount
+ ? Math.floor(Math.random() * (drop.amount.max - drop.amount.min + 1) + drop.amount.min)
+ : 1;
+ useManaStore.getState().addElementMana(elem, amount, elemState.max);
+ get().addActivityLog('special_effect', `Found ${amount}x ${drop.name}`);
+ }
+ }
+ } else if (drop.type === 'blueprint') {
+ get().addActivityLog('special_effect', `Found blueprint: ${drop.name}`);
+ }
+ }
+
+ if (progress >= required) {
+ set({ currentRoom: { ...room, treasureProgress: progress, treasureLootClaimed: Array.from(claimedSet) } });
+ advanceRoomOrFloor(get, set);
+ } else {
+ set({ currentRoom: { ...room, treasureProgress: progress, treasureLootClaimed: Array.from(claimedSet) } });
+ }
+}
+
+function tickPuzzleRoom(get: GetFn, set: SetFn, hours: number): void {
+ const s = get();
+ const room = s.currentRoom;
+ const progress = (room.puzzleProgress || 0) + hours;
+ const required = room.puzzleRequired || 1;
+
+ if (progress >= required) {
+ set({ currentRoom: { ...room, puzzleProgress: progress } });
+ get().addActivityLog('puzzle_solved', `Puzzle solved on Floor ${s.currentFloor}!`);
+ advanceRoomOrFloor(get, set);
+ } else {
+ set({ currentRoom: { ...room, puzzleProgress: progress } });
+ }
+}
+
+// ─── Player Actions ───────────────────────────────────────────────────────────
+
+export function skipNonCombatRoom(get: GetFn, set: SetFn): void {
+ const s = get();
+ const rt = s.currentRoom.roomType as string;
+ if (rt === 'library' || rt === 'recovery' || rt === 'treasure') {
+ get().addActivityLog('floor_transition', `Skipped ${rt} room on Floor ${s.currentFloor}`);
+ advanceRoomOrFloor(get, set);
+ }
+}
+
+export function stayLongerInRoom(get: GetFn, set: SetFn): void {
+ const s = get();
+ const room = s.currentRoom;
+ const rt = room.roomType as string;
+
+ if (rt === 'library' && !room.libraryStayed) {
+ set({
+ currentRoom: {
+ ...room,
+ libraryRequired: (room.libraryRequired || 1) + 1,
+ libraryStayed: true,
+ },
+ });
+ get().addActivityLog('special_effect', 'Decided to study for 1 more hour in the library');
+ } else if (rt === 'recovery' && !room.recoveryStayed) {
+ set({
+ currentRoom: {
+ ...room,
+ recoveryRequired: (room.recoveryRequired || 1) + 1,
+ recoveryStayed: true,
+ },
+ });
+ get().addActivityLog('special_effect', 'Decided to rest for 1 more hour in the recovery room');
+ }
+}
diff --git a/src/lib/game/types/game.ts b/src/lib/game/types/game.ts
index 809c069..13b2767 100644
--- a/src/lib/game/types/game.ts
+++ b/src/lib/game/types/game.ts
@@ -81,9 +81,16 @@ export interface FloorState {
// Recovery room fields
recoveryProgress?: number;
recoveryRequired?: number;
+ recoveryStayed?: boolean;
// Library room fields
libraryProgress?: number;
libraryRequired?: number;
+ libraryStayed?: boolean;
+ // Treasure room fields
+ treasureProgress?: number;
+ treasureRequired?: number;
+ treasureLoot?: LootDrop[];
+ treasureLootClaimed?: number[];
}
// ─── Achievement Types ─────────────────────────────────────────────────────
diff --git a/src/lib/game/utils/spire-utils.ts b/src/lib/game/utils/spire-utils.ts
index 4a5318d..12595eb 100644
--- a/src/lib/game/utils/spire-utils.ts
+++ b/src/lib/game/utils/spire-utils.ts
@@ -2,7 +2,9 @@
// Spire-specific utility functions for room generation, enemy stat scaling, etc.
import type { RoomType, FloorState, EnemyState } from '../types';
+import type { LootDrop } from '../types/game';
import { PUZZLE_ROOMS } from '../constants';
+import { getAvailableDrops, rollLootDrops } from '../data/loot-drops';
import { getFloorMaxHP, getFloorElement } from './floor-utils';
import { getEnemyName } from './enemy-utils';
import { getGuardianForFloor, isGuardianFloor } from '../data/guardian-encounters';
@@ -159,11 +161,17 @@ export function generateSpireFloorState(floor: number, roomIndex: number, totalR
libraryRequired: 1,
};
- case 'treasure':
+ case 'treasure': {
+ const loot = generateTreasureLoot(floor);
return {
roomType: 'treasure',
enemies: [],
+ treasureProgress: 0,
+ treasureRequired: 1,
+ treasureLoot: loot,
+ treasureLootClaimed: [],
};
+ }
default:
return generateCombatRoom(floor, element, baseHP);
@@ -267,6 +275,52 @@ export function calcInsight(floor: number, isGuardian: boolean): number {
// ─── Room Type Display ────────────────────────────────────────────────────────
+/**
+ * Generate treasure loot for a treasure room based on floor.
+ * Returns pre-generated loot drops that are progressively revealed.
+ */
+export function generateTreasureLoot(floor: number): LootDrop[] {
+ const available = getAvailableDrops(floor, false);
+ const drops: LootDrop[] = [];
+
+ // Determine item count based on floor
+ let itemCount: number;
+ if (floor <= 10) {
+ itemCount = 2 + Math.floor(Math.random() * 2); // 2-3
+ } else if (floor <= 50) {
+ itemCount = 4 + Math.floor(Math.random() * 4); // 4-7
+ } else {
+ itemCount = 8 + Math.floor(Math.random() * 8); // 8-15
+ }
+
+ // Roll for each item
+ for (let i = 0; i < itemCount; i++) {
+ const rolled = rollLootDrops(floor, false, 0);
+ for (const { drop, amount } of rolled) {
+ // For materials, add amount; for others, just add the drop
+ const existing = drops.find(d => d.id === drop.id);
+ if (existing && existing.amount) {
+ existing.amount = {
+ min: existing.amount.min + (drop.amount?.min ?? 1),
+ max: existing.amount.max + (drop.amount?.max ?? amount),
+ };
+ } else {
+ drops.push({ ...drop, amount: drop.amount || { min: amount, max: amount } });
+ }
+ }
+ }
+
+ // Ensure at least one item
+ if (drops.length === 0) {
+ const fallback = available.find(d => d.type === 'material' && d.rarity === 'common');
+ if (fallback) {
+ drops.push({ ...fallback, amount: { min: 1, max: 3 } });
+ }
+ }
+
+ return drops;
+}
+
export function getSpireRoomTypeDisplay(roomType: SpireRoomType): { label: string; icon: string; color: string } {
const displays: Record = {
combat: { label: 'Combat', icon: '⚔️', color: '#EF4444' },