feat: implement non-combat room gameplay (Library, Recovery, Treasure, Puzzle)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m4s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m4s
This commit is contained in:
@@ -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)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* HP bar */}
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={hpPercent}
|
||||
@@ -56,8 +54,6 @@ function EnemyRow({ enemy }: { enemy: EnemyState }) {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Enemy stats */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{enemy.armor > 0 && (
|
||||
<Badge variant="outline" className="text-[10px] border-amber-600 text-amber-400">
|
||||
@@ -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 (
|
||||
<CardTitle className="text-sm flex items-center gap-2" style={{ color }}>
|
||||
{icon} {label}
|
||||
{roomLabel && <Badge variant="outline" className="text-[10px] border-gray-600 text-gray-400">{roomLabel}</Badge>}
|
||||
{timeLabel && <span className="text-[10px] text-gray-500 ml-auto">{timeLabel}</span>}
|
||||
</CardTitle>
|
||||
);
|
||||
}
|
||||
|
||||
function ProgressBar({ progress, required, color }: { progress: number; required: number; color: string }) {
|
||||
const pct = required > 0 ? Math.min((progress / required) * 100, 100) : 0;
|
||||
return (
|
||||
<Progress
|
||||
value={pct}
|
||||
className="h-2 bg-gray-800"
|
||||
style={{ '--progress-bg': color } as React.CSSProperties}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoomDisplay({ floorState, floor, roomIndex, totalRooms, day, hour, onSkip, onStayLonger }: RoomDisplayProps) {
|
||||
if (!floorState || !floorState.roomType) {
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
@@ -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 (
|
||||
<Card className="bg-gray-900/80 border-green-800/40">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#10B981' }}>
|
||||
💚 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>
|
||||
<RoomHeader icon="💚" label="Recovery Room" color="#10B981" roomLabel={roomLabel} timeLabel={timeLabel} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
Rest and recover. Spend 1 hour to gain 5x mana regen & conversion rates.
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-gray-400">
|
||||
Resting and recovering in a mana-rich chamber. Mana regen and conversion rates are 10× faster.
|
||||
</p>
|
||||
<Progress
|
||||
value={(progress / required) * 100}
|
||||
className="h-2 bg-gray-800"
|
||||
style={{ '--progress-bg': '#10B981' } as React.CSSProperties}
|
||||
/>
|
||||
<ProgressBar progress={progress} required={required} color="#10B981" />
|
||||
<div className="text-xs text-gray-500">
|
||||
{Math.round(progress * 100) / 100} / {required} hours
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{onSkip && (
|
||||
<Button variant="outline" size="sm" onClick={onSkip} className="text-xs border-green-700 text-green-400 hover:bg-green-900/30">
|
||||
Skip
|
||||
</Button>
|
||||
)}
|
||||
{onStayLonger && (
|
||||
<Button variant="outline" size="sm" onClick={onStayLonger} disabled={stayed}
|
||||
className="text-xs border-green-700 text-green-400 hover:bg-green-900/30 disabled:opacity-40">
|
||||
{stayed ? 'Already Stayed' : 'Stay 1 Hour More'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Library Room ───────────────────────────────────────────────────────
|
||||
if (rt === 'library') {
|
||||
const progress = floorState.libraryProgress || 0;
|
||||
const required = floorState.libraryRequired || 1;
|
||||
const stayed = floorState.libraryStayed || false;
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-indigo-800/40">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#6366F1' }}>
|
||||
📚 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>
|
||||
<RoomHeader icon="📚" label="Ancient Library" color="#6366F1" roomLabel={roomLabel} timeLabel={timeLabel} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-gray-400">
|
||||
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.
|
||||
</p>
|
||||
<ProgressBar progress={progress} required={required} color="#6366F1" />
|
||||
<div className="text-xs text-gray-500">
|
||||
{Math.round(progress * 100) / 100} / {required} hours
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{onSkip && (
|
||||
<Button variant="outline" size="sm" onClick={onSkip} className="text-xs border-indigo-700 text-indigo-400 hover:bg-indigo-900/30">
|
||||
Skip
|
||||
</Button>
|
||||
)}
|
||||
{onStayLonger && (
|
||||
<Button variant="outline" size="sm" onClick={onStayLonger} disabled={stayed}
|
||||
className="text-xs border-indigo-700 text-indigo-400 hover:bg-indigo-900/30 disabled:opacity-40">
|
||||
{stayed ? 'Already Stayed' : 'Stay 1 Hour More'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Treasure Room ──────────────────────────────────────────────────────
|
||||
if (rt === 'treasure') {
|
||||
const progress = floorState.treasureProgress || 0;
|
||||
const required = floorState.treasureRequired || 1;
|
||||
const loot = floorState.treasureLoot || [];
|
||||
const claimed = floorState.treasureLootClaimed || [];
|
||||
return (
|
||||
<Card className="bg-gray-900/80 border-amber-800/40">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm flex items-center gap-2" style={{ color: '#F59E0B' }}>
|
||||
💎 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>
|
||||
<RoomHeader icon="💎" label="Treasure Room" color="#F59E0B" roomLabel={roomLabel} timeLabel={timeLabel} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-gray-400">
|
||||
A hidden cache of resources awaits. Claim your reward!
|
||||
Rummaging through ancient chests and caches. Loot is revealed progressively as you search.
|
||||
</p>
|
||||
<ProgressBar progress={progress} required={required} color="#F59E0B" />
|
||||
<div className="text-xs text-gray-500">
|
||||
{Math.round(progress * 100) / 100} / {required} hours
|
||||
</div>
|
||||
{claimed.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-gray-400 font-medium">Loot found so far:</div>
|
||||
{claimed.map((idx) => {
|
||||
const drop = loot[idx];
|
||||
if (!drop) return null;
|
||||
return (
|
||||
<div key={idx} className="text-xs text-amber-300">
|
||||
• {drop.name}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{onSkip && (
|
||||
<Button variant="outline" size="sm" onClick={onSkip} className="text-xs border-amber-700 text-amber-400 hover:bg-amber-900/30">
|
||||
Skip (forfeit remaining loot)
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<Card className="bg-gray-900/80 border-purple-800/40">
|
||||
<CardHeader className="pb-2">
|
||||
@@ -184,24 +257,23 @@ export function RoomDisplay({ floorState, floor, roomIndex, totalRooms, day, hou
|
||||
{timeLabel && <span className="text-[10px] text-gray-500 ml-auto">{timeLabel}</span>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
Solve the puzzle. Higher attunement levels speed up progress.
|
||||
</p>
|
||||
<Progress
|
||||
value={(progress / required) * 100}
|
||||
className="h-2 bg-gray-800"
|
||||
style={{ '--progress-bg': '#8B5CF6' } as React.CSSProperties}
|
||||
/>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Progress: {Math.round(progress * 100)} / {Math.round(required * 100)}
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-gray-400">{thematicText}. Higher attunement levels speed up progress.</p>
|
||||
<ProgressBar progress={progress} required={required} color="#8B5CF6" />
|
||||
<div className="text-xs text-gray-500">
|
||||
{Math.round(progress * 100) / 100} / {Math.round(required * 100) / 100} hours
|
||||
</div>
|
||||
{attunements.length > 0 && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Relevant attunements: {attunements.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
<CardTitle className="text-sm flex items-center gap-2" style={{ color: roomDisplay.color }}>
|
||||
{roomDisplay.icon} {roomDisplay.label}
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<CombatStore>()(
|
||||
persist(
|
||||
@@ -221,6 +220,9 @@ export const useCombatStore = create<CombatStore>()(
|
||||
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) => {
|
||||
|
||||
@@ -262,6 +262,26 @@ export const useGameStore = create<GameCoordinatorStore>()(
|
||||
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) {
|
||||
|
||||
@@ -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<CombatState>) => 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<string, string> = {
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<string, { label: string; icon: string; color: string }> = {
|
||||
combat: { label: 'Combat', icon: '⚔️', color: '#EF4444' },
|
||||
|
||||
Reference in New Issue
Block a user