From ee24227d62541b97567c6c29c46629da80ee3a43 Mon Sep 17 00:00:00 2001 From: n8n-gitea Date: Thu, 4 Jun 2026 19:28:25 +0200 Subject: [PATCH] feat: implement non-combat room gameplay (Library, Recovery, Treasure, Puzzle) --- docs/circular-deps.txt | 2 +- docs/dependency-graph.json | 7 +- docs/project-structure.txt | 1 + .../game/tabs/SpireCombatPage/RoomDisplay.tsx | 196 ++++++++---- .../tabs/SpireCombatPage/SpireCombatPage.tsx | 6 + src/lib/game/stores/combat-descent-actions.ts | 63 +--- src/lib/game/stores/combat-state.types.ts | 6 + src/lib/game/stores/combatStore.ts | 12 +- src/lib/game/stores/gameStore.ts | 20 ++ .../game/stores/non-combat-room-actions.ts | 287 ++++++++++++++++++ src/lib/game/types/game.ts | 7 + src/lib/game/utils/spire-utils.ts | 56 +++- 12 files changed, 539 insertions(+), 124 deletions(-) create mode 100644 src/lib/game/stores/non-combat-room-actions.ts diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index 22715d5..01df1f4 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,4 +1,4 @@ # Circular Dependencies -Generated: 2026-06-04T16:12:56.097Z +Generated: 2026-06-04T16:54:47.153Z No circular dependencies found. ✅ diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index edd1261..b8b9cfa 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-06-04T16:12:54.221Z", + "generated": "2026-06-04T16:54:45.269Z", "description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry." }, @@ -554,7 +554,6 @@ "stores/golem-combat-actions.ts", "stores/manaStore.ts", "stores/prestigeStore.ts", - "types.ts", "utils/spire-utils.ts" ], "stores/combat-state.types.ts": [ @@ -568,8 +567,8 @@ "types.ts", "utils/activity-log.ts", "utils/index.ts", - "utils/room-utils.ts", - "utils/safe-persist.ts" + "utils/safe-persist.ts", + "utils/spire-utils.ts" ], "stores/crafting-equipment-tick.ts": [ "constants.ts", diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 29a8d4b..689dd26 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -375,6 +375,7 @@ Mana-Loop/ │ │ │ │ ├── golem-combat-actions.ts │ │ │ │ ├── index.ts │ │ │ │ ├── manaStore.ts +│ │ │ │ ├── non-combat-room-actions.ts │ │ │ │ ├── prestigeStore.ts │ │ │ │ ├── tick-pipeline.ts │ │ │ │ └── uiStore.ts diff --git a/src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx b/src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx index a9e55f9..022fdcf 100644 --- a/src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx +++ b/src/components/game/tabs/SpireCombatPage/RoomDisplay.tsx @@ -4,6 +4,7 @@ import type { FloorState, EnemyState, RoomType } from '@/lib/game/types'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Progress } from '@/components/ui/progress'; +import { Button } from '@/components/ui/button'; import { getSpireRoomTypeDisplay } from '@/lib/game/utils/spire-utils'; import { ELEMENTS } from '@/lib/game/constants'; import { fmt } from '@/lib/game/stores'; @@ -12,13 +13,12 @@ import { DebugName } from '@/components/game/debug/debug-context'; interface RoomDisplayProps { floorState: FloorState; 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; + onSkip?: () => void; + onStayLonger?: () => void; } function EnemyRow({ enemy }: { enemy: EnemyState }) { @@ -41,8 +41,6 @@ function EnemyRow({ enemy }: { enemy: EnemyState }) { {fmt(enemy.hp)} / {fmt(enemy.maxHP)} - - {/* HP bar */}
)}
- - {/* Enemy stats */}
{enemy.armor > 0 && ( @@ -79,8 +75,30 @@ function EnemyRow({ enemy }: { enemy: EnemyState }) { ); } -export function RoomDisplay({ floorState, floor, roomIndex, totalRooms, day, hour }: RoomDisplayProps) { - // Guard against null/undefined/stale floorState +function RoomHeader({ icon, label, color, roomLabel, timeLabel }: { + icon: string; label: string; color: string; roomLabel: string | null; timeLabel: string | null; +}) { + return ( + + {icon} {label} + {roomLabel && {roomLabel}} + {timeLabel && {timeLabel}} + + ); +} + +function ProgressBar({ progress, required, color }: { progress: number; required: number; color: string }) { + const pct = required > 0 ? Math.min((progress / required) * 100, 100) : 0; + return ( + + ); +} + +export function RoomDisplay({ floorState, floor, roomIndex, totalRooms, day, hour, onSkip, onStayLonger }: RoomDisplayProps) { if (!floorState || !floorState.roomType) { return ( @@ -92,89 +110,144 @@ export function RoomDisplay({ floorState, floor, roomIndex, totalRooms, day, hou } 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) + const roomLabel = showRoomIndex ? `Room ${roomIndex + 1} / ${totalRooms}` : null; + const timeLabel = day !== undefined && hour !== undefined ? `D${day} H${Math.floor(hour)}` : null; const rt = floorState.roomType as string; + // ─── Recovery Room ────────────────────────────────────────────────────── if (rt === 'recovery') { const progress = floorState.recoveryProgress || 0; const required = floorState.recoveryRequired || 1; + const stayed = floorState.recoveryStayed || false; return ( - - 💚 Recovery Room - {roomLabel && {roomLabel}} - {timeLabel && {timeLabel}} - + - -

- Rest and recover. Spend 1 hour to gain 5x mana regen & conversion rates. + +

+ Resting and recovering in a mana-rich chamber. Mana regen and conversion rates are 10× faster.

- + +
+ {Math.round(progress * 100) / 100} / {required} hours +
+
+ {onSkip && ( + + )} + {onStayLonger && ( + + )} +
); } + // ─── Library Room ─────────────────────────────────────────────────────── if (rt === 'library') { + const progress = floorState.libraryProgress || 0; + const required = floorState.libraryRequired || 1; + const stayed = floorState.libraryStayed || false; return ( - - 📚 Ancient Library - {roomLabel && {roomLabel}} - {timeLabel && {timeLabel}} - + - +

- Study a random discipline at 10x XP speed (no mana cost). Spend 1 hour to gain knowledge. + Studying Mana Circulation from ancient tomes. A random discipline gains XP at 25× the normal rate.

+ +
+ {Math.round(progress * 100) / 100} / {required} hours +
+
+ {onSkip && ( + + )} + {onStayLonger && ( + + )} +
); } + // ─── Treasure Room ────────────────────────────────────────────────────── if (rt === 'treasure') { + const progress = floorState.treasureProgress || 0; + const required = floorState.treasureRequired || 1; + const loot = floorState.treasureLoot || []; + const claimed = floorState.treasureLootClaimed || []; return ( - - 💎 Treasure Room - {roomLabel && {roomLabel}} - {timeLabel && {timeLabel}} - + - +

- A hidden cache of resources awaits. Claim your reward! + Rummaging through ancient chests and caches. Loot is revealed progressively as you search.

+ +
+ {Math.round(progress * 100) / 100} / {required} hours +
+ {claimed.length > 0 && ( +
+
Loot found so far:
+ {claimed.map((idx) => { + const drop = loot[idx]; + if (!drop) return null; + return ( +
+ • {drop.name} +
+ ); + })} +
+ )} + {onSkip && ( + + )}
); } - if (floorState.roomType === 'puzzle') { + // ─── Puzzle Room ──────────────────────────────────────────────────────── + if (rt === 'puzzle') { const puzzleId = floorState.puzzleId || 'unknown'; const progress = floorState.puzzleProgress || 0; const required = floorState.puzzleRequired || 1; + const attunements = floorState.puzzleAttunements || []; + + const puzzleNames: Record = { + enchanter_trial: 'Deciphering an enchanted lock', + fabricator_trial: 'Disassembling a mana-powered mechanism', + invoker_trial: 'Communing with residual guardian spirits', + hybrid_enchanter_fabricator: 'Working through a complex attunement challenge', + hybrid_enchanter_invoker: 'Working through a complex attunement challenge', + hybrid_fabricator_invoker: 'Working through a complex attunement challenge', + }; + + const thematicText = puzzleNames[puzzleId] || 'Working through a complex attunement challenge'; + return ( @@ -184,24 +257,23 @@ export function RoomDisplay({ floorState, floor, roomIndex, totalRooms, day, hou {timeLabel && {timeLabel}} - -

- 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' },