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>
|
||||
|
||||
Reference in New Issue
Block a user