feat: implement non-combat room gameplay (Library, Recovery, Treasure, Puzzle)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m4s

This commit is contained in:
2026-06-04 19:28:25 +02:00
parent 40a50d34f4
commit ee24227d62
12 changed files with 539 additions and 124 deletions
@@ -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>