fix: bugs #238,#240,#244,#246 + docs #248 update
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s

- #238: Fix spire tab inconsistent state (Max Floor 1 but Floors Cleared 0) by not inflating maxFloorReached on enterSpireMode and preserving it on exitSpireMode
- #240: Fix guardian armor display stray text by extracting stat formatters in SpireSummaryTab
- #244: Improve discipline auto-pause UX with log messages and visual feedback on DisciplineCard
- #246: Fix raw mana exceeding max cap by recomputing maxMana after discipline XP gains
- #248: Update AGENTS.md (remove gitea_get_project_boards, add gitea_start_session, 22 mana types, 8 stores, updated guardian tiers)
- #248: Update README.md (remove Prisma/SQLite refs, update mana types/guardian tiers/discipline counts)
- #248: Update GAME_BRIEFING.md (8 stores, 22 mana types, 64 disciplines, 8-tier guardians, correct code architecture)
This commit is contained in:
2026-06-01 13:54:28 +02:00
parent 7dd9ad5b92
commit fa78c7a93a
15 changed files with 448 additions and 407 deletions
+9 -1
View File
@@ -12,6 +12,7 @@ export interface DisciplineCardProps {
definition: DisciplineDefinition;
xp: number;
paused: boolean;
autoPaused?: boolean;
activeIds: string[];
concurrentLimit: number;
isLocked: boolean;
@@ -23,7 +24,7 @@ export interface DisciplineCardProps {
// ─── Component ────────────────────────────────────────────────────────────────
export const DisciplineCard: React.FC<DisciplineCardProps> = ({
definition, xp, paused: isPaused, activeIds, concurrentLimit,
definition, xp, paused: isPaused, autoPaused, activeIds, concurrentLimit,
isLocked, missingPrereqs, missingSourceMana, onToggle,
}) => {
const {
@@ -159,6 +160,13 @@ export const DisciplineCard: React.FC<DisciplineCardProps> = ({
</div>
)}
{/* Auto-paused mana feedback (fix #244) */}
{isActive && isPaused && autoPaused && (
<div className="mt-2 text-xs text-amber-400 bg-amber-900/20 rounded px-2 py-1">
Auto-paused insufficient {manaName} mana to continue practicing.
</div>
)}
{/* Action Button */}
<div className="mt-4 flex justify-end">
<button
+5 -5
View File
@@ -1,6 +1,6 @@
import React, { useState, useCallback } from 'react';
import { useDisciplineStore } from '@/lib/game/stores/discipline-slice';
import type { DisciplineDefinition } from '@/lib/game/types/disciplines';
import type { DisciplineDefinition, DisciplineState } from '@/lib/game/types/disciplines';
import { baseDisciplines } from '@/lib/game/data/disciplines/base';
import { elementalAttunementDisciplines } from '@/lib/game/data/disciplines/elemental';
import { elementalRegenDisciplines } from '@/lib/game/data/disciplines/elemental-regen';
@@ -40,7 +40,7 @@ const ATTUNEMENT_TABS: AttunementTab[] = [
interface CardWrapperProps {
disc: DisciplineDefinition;
disciplines: Record<string, { xp: number; paused: boolean }>;
disciplines: Record<string, DisciplineState>;
activeIds: string[];
concurrentLimit: number;
elements: ReturnType<typeof useManaStore.getState>['elements'];
@@ -58,6 +58,7 @@ const CardWrapper: React.FC<CardWrapperProps> = ({
definition={disc}
xp={discState.xp}
paused={discState.paused}
autoPaused={discState.autoPaused}
activeIds={activeIds}
concurrentLimit={concurrentLimit}
isLocked={!prereqCheck.canProceed}
@@ -83,17 +84,16 @@ export const DisciplinesTab: React.FC = () => {
const [activeAttunement, setActiveAttunement] = useState<string>('base');
const rawMana = useManaStore((s) => s.rawMana);
const elements = useManaStore((s) => s.elements);
const signedPacts = usePrestigeStore((s) => s.signedPacts);
const handleToggle = useCallback((id: string, paused: boolean) => {
if (paused) {
activate(id, { elements, signedPacts, rawMana });
activate(id, { elements, signedPacts });
} else {
deactivate(id);
}
}, [activate, deactivate, rawMana, elements, signedPacts]);
}, [activate, deactivate, elements, signedPacts]);
const activeTab = ATTUNEMENT_TABS.find((t) => t.key === activeAttunement);
+3 -2
View File
@@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import type { DisciplineDefinition } from '@/lib/game/types/disciplines';
import type { DisciplineDefinition, DisciplineState } from '@/lib/game/types/disciplines';
import { ELEMENTS } from '@/lib/game/constants/elements';
import { elementalAttunementDisciplines } from '@/lib/game/data/disciplines/elemental';
import { elementalRegenDisciplines } from '@/lib/game/data/disciplines/elemental-regen';
@@ -49,7 +49,7 @@ function buildElementalDisciplineMap(
// ─── Shared Props ─────────────────────────────────────────────────────────────
interface SharedRenderProps {
disciplines: Record<string, { xp: number; paused: boolean }>;
disciplines: Record<string, DisciplineState>;
activeIds: string[];
concurrentLimit: number;
elements: DisciplineCardProps['missingSourceMana'] extends readonly string[]
@@ -96,6 +96,7 @@ const ElementalDisciplineGroup: React.FC<GroupProps & { activeIds: string[] }> =
definition={def}
xp={discState.xp}
paused={discState.paused}
autoPaused={discState.autoPaused}
activeIds={activeIds}
concurrentLimit={concurrentLimit}
isLocked={!prereqCheck.canProceed}
@@ -0,0 +1,249 @@
'use client';
import { useMemo } from 'react';
import { fmt } from '@/lib/game/stores';
import { ELEMENT_OPPOSITES, FLOOR_ELEM_CYCLE } from '@/lib/game/constants';
import { getGuardianForFloor, getAllGuardianFloors } from '@/lib/game/data/guardian-encounters';
import type { GuardianDef } from '@/lib/game/types';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { SectionHeader } from '@/components/ui/section-header';
const GUARDIAN_FLOORS = getAllGuardianFloors();
// ─── Helper: Get Counter Element ─────────────────────────────────────────────
export function getCounterElement(element: string): string | null {
return ELEMENT_OPPOSITES[element] || null;
}
export function getElementColor(element: string): string {
const colors: Record<string, string> = {
fire: '#FF6B35',
water: '#4ECDC4',
air: '#00D4FF',
earth: '#F4A261',
light: '#FFD700',
dark: '#9B59B6',
death: '#778CA3',
void: '#4A235A',
stellar: '#F0E68C',
};
return colors[element] || '#9CA3AF';
}
// ─── Guardian Stat Formatters ────────────────────────────────────────────────
export function fmtArmor(armor: number | undefined): React.ReactNode {
if (!armor || armor <= 0) return null;
return (
<span className="text-xs text-gray-500">
{'Armor: '}{Math.round(armor * 100)}{'%'}
</span>
);
}
export function fmtShield(shield: number | undefined): React.ReactNode {
if (!shield || shield <= 0) return null;
return (
<span className="text-xs text-cyan-400">
{'Shield: '}{fmt(shield)}
</span>
);
}
export function fmtBarrier(barrier: number | undefined): React.ReactNode {
if (!barrier || barrier <= 0) return null;
return (
<span className="text-xs text-blue-400">
{'Barrier: '}{Math.round(barrier * 100)}{'%'}
</span>
);
}
export function fmtRegen(regen: number | undefined, isPercent: boolean | undefined): React.ReactNode {
if (!regen || regen <= 0) return null;
return (
<span className="text-xs text-green-400">
{'Regen: '}{isPercent ? `${regen}%/tick` : `${regen}/tick`}
</span>
);
}
// ─── Preparation Tips ────────────────────────────────────────────────────────
export function PreparationTips({ counterElement, nextFloorElement, hasHighArmor }: {
counterElement: string | null; nextFloorElement: string | null; hasHighArmor: boolean;
}) {
return (
<div className="bg-gray-800/50 rounded-lg p-3 space-y-2">
<div className="text-xs font-medium text-gray-300">Recommended Preparation:</div>
<div className="text-xs text-gray-400 space-y-1">
{counterElement && (
<div className="flex items-center gap-2">
<span className="text-emerald-400"></span>
<span>
Use <span style={{ color: getElementColor(counterElement) }} className="font-medium">{counterElement}</span> spells for super effective damage (+50%)
</span>
</div>
)}
{nextFloorElement && (
<div className="flex items-center gap-2">
<span className="text-blue-400">🔄</span>
<span>
Floor element: <span style={{ color: getElementColor(nextFloorElement) }} className="font-medium">{nextFloorElement}</span>
</span>
</div>
)}
{hasHighArmor && (
<div className="flex items-center gap-2">
<span className="text-red-400">🛡</span>
<span>High armor consider armor-piercing or raw damage spells</span>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-amber-400">💡</span>
<span>Ensure mana pools are full before attempting</span>
</div>
</div>
</div>
);
}
// ─── Guardian Roster ─────────────────────────────────────────────────────────
export function GuardianRoster({ clearedFloors }: { clearedFloors: Record<number, boolean> }) {
return (
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="🏛️ Guardian Roster" />
<CardContent className="pt-0">
<div className="space-y-2">
{GUARDIAN_FLOORS.map((floor) => {
const guardian = getGuardianForFloor(floor);
return guardian ? (
<GuardianRosterItem key={floor} floor={floor} guardian={guardian} isDefeated={!!clearedFloors[floor]} />
) : null;
})}
</div>
</CardContent>
</Card>
);
}
function GuardianRosterItem({ floor, guardian, isDefeated }: { floor: number; guardian: GuardianDef; isDefeated: boolean }) {
return (
<div
className={`flex items-center justify-between p-2 rounded border ${
isDefeated
? 'bg-emerald-900/20 border-emerald-800/40'
: 'bg-gray-800/40 border-gray-700/50'
}`}
>
<div className="flex items-center gap-2">
<div
className="w-7 h-7 rounded flex items-center justify-center text-xs font-bold"
style={{
backgroundColor: isDefeated ? `${guardian.color}30` : '#374151',
color: isDefeated ? guardian.color : '#6B7280',
}}
>
{floor}
</div>
<div>
<div className={`text-sm font-medium ${isDefeated ? 'text-gray-100' : 'text-gray-400'}`}>
{guardian.name}
</div>
<div className="flex items-center gap-1.5">
<span
className="text-[10px] px-1.5 py-0.5 rounded"
style={{
backgroundColor: `${guardian.color}15`,
color: guardian.color,
}}
>
{guardian.element.join(' + ')}
</span>
<span className="text-[10px] text-gray-500">Health: {fmt(guardian.hp)}</span>
</div>
</div>
</div>
<div>
{isDefeated ? (
<Badge variant="outline" className="border-emerald-600 text-emerald-400 text-xs">
Defeated
</Badge>
) : (
<Badge variant="outline" className="border-gray-600 text-gray-500 text-xs">
Undefeated
</Badge>
)}
</div>
</div>
);
}
// ─── Floor Progress Bar ────────────────────────────────────────────────────────
export function FloorProgressBar({ maxFloor, clearedFloors }: { maxFloor: number; clearedFloors: Record<number, boolean> }) {
const totalFloors = Math.min(maxFloor, 100);
const clearedSet = new Set(Object.entries(clearedFloors).filter(([, v]) => v).map(([k]) => Number(k)));
const rows: number[][] = [];
for (let i = 0; i < totalFloors; i += 10) {
rows.push(Array.from({ length: 10 }, (_, j) => i + j + 1).filter((f) => f <= totalFloors));
}
return (
<div className="space-y-1">
{rows.reverse().map((row) => (
<div key={row[0]} className="flex gap-1">
{row.map((floor) => {
const isCleared = clearedSet.has(floor);
const isGuardian = !!getGuardianForFloor(floor);
const isCurrent = floor === maxFloor;
let bgClass = 'bg-gray-800';
if (isCleared) bgClass = 'bg-emerald-600/60';
else if (isCurrent) bgClass = 'bg-amber-600/60';
const borderClass = isGuardian ? 'border-amber-500' : isCurrent ? 'border-amber-400' : 'border-gray-700';
return (
<div
key={floor}
className={`w-7 h-7 flex items-center justify-center text-[9px] rounded border ${bgClass} ${borderClass} ${isGuardian ? 'font-bold' : ''}`}
title={getGuardianForFloor(floor) ? `Floor ${floor}${getGuardianForFloor(floor)!.name} (${getGuardianForFloor(floor)!.element.join(' + ')})` : `Floor ${floor}${isCleared ? ' (cleared)' : ''}`}
>
{floor}
</div>
);
})}
</div>
))}
<FloorLegend />
</div>
);
}
function FloorLegend() {
return (
<div className="flex items-center gap-3 mt-2 text-[10px] text-gray-500">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-emerald-600/60 border border-gray-700" />
<span>Cleared</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-800 border border-gray-700" />
<span>Uncleared</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-800 border border-amber-500" />
<span>Guardian</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-amber-600/60 border border-amber-400" />
<span>Current</span>
</div>
</div>
);
}
+29 -292
View File
@@ -2,125 +2,23 @@
import { useMemo } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useCombatStore, usePrestigeStore, fmt } from '@/lib/game/stores';
import { ELEMENT_OPPOSITES, FLOOR_ELEM_CYCLE } from '@/lib/game/constants';
import { getGuardianForFloor, getAllGuardianFloors } from '@/lib/game/data/guardian-encounters';
import type { GuardianDef } from '@/lib/game/types';
import { useCombatStore, usePrestigeStore } from '@/lib/game/stores';
import { FLOOR_ELEM_CYCLE } from '@/lib/game/constants';
import { getGuardianForFloor } from '@/lib/game/data/guardian-encounters';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { SectionHeader } from '@/components/ui/section-header';
import { DebugName } from '@/components/game/debug/debug-context';
// ─── Guardian Data ────────────────────────────────────────────────────────────
const GUARDIAN_FLOORS = getAllGuardianFloors();
// ─── Helper: Get Counter Element ─────────────────────────────────────────────
function getCounterElement(element: string): string | null {
return ELEMENT_OPPOSITES[element] || null;
}
function getElementColor(element: string): string {
const colors: Record<string, string> = {
fire: '#FF6B35',
water: '#4ECDC4',
air: '#00D4FF',
earth: '#F4A261',
light: '#FFD700',
dark: '#9B59B6',
death: '#778CA3',
void: '#4A235A',
stellar: '#F0E68C',
};
return colors[element] || '#9CA3AF';
}
// ─── Sub-component: Floor Progress Bar ────────────────────────────────────────
function FloorProgressBar({ maxFloor, clearedFloors }: { maxFloor: number; clearedFloors: Record<number, boolean> }) {
const totalFloors = Math.min(maxFloor, 100);
const clearedSet = new Set(Object.entries(clearedFloors).filter(([, v]) => v).map(([k]) => Number(k)));
const rows: number[][] = [];
for (let i = 0; i < totalFloors; i += 10) {
rows.push(Array.from({ length: 10 }, (_, j) => i + j + 1).filter((f) => f <= totalFloors));
}
return (
<div className="space-y-1">
{rows.reverse().map((row) => (
<div key={row[0]} className="flex gap-1">
{row.map((floor) => {
const isCleared = clearedSet.has(floor);
const isGuardian = !!getGuardianForFloor(floor);
const isCurrent = floor === maxFloor;
let bgClass = 'bg-gray-800';
if (isCleared) bgClass = 'bg-emerald-600/60';
else if (isCurrent) bgClass = 'bg-amber-600/60';
const borderClass = isGuardian
? 'border-amber-500'
: isCurrent
? 'border-amber-400'
: 'border-gray-700';
return (
<div
key={floor}
className={`w-7 h-7 flex items-center justify-center text-[9px] rounded border ${bgClass} ${borderClass} ${
isGuardian ? 'font-bold' : ''
}`}
title={
getGuardianForFloor(floor)
? `Floor ${floor}${getGuardianForFloor(floor)!.name} (${getGuardianForFloor(floor)!.element.join(' + ')})`
: `Floor ${floor}${isCleared ? ' (cleared)' : ''}`
}
>
{floor}
</div>
);
})}
</div>
))}
<FloorLegend />
</div>
);
}
function FloorLegend() {
return (
<div className="flex items-center gap-3 mt-2 text-[10px] text-gray-500">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-emerald-600/60 border border-gray-700" />
<span>Cleared</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-800 border border-gray-700" />
<span>Uncleared</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-gray-800 border border-amber-500" />
<span>Guardian</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded bg-amber-600/60 border border-amber-400" />
<span>Current</span>
</div>
</div>
);
}
import {
getCounterElement, getElementColor, fmtArmor, fmtShield, fmtBarrier, fmtRegen,
PreparationTips, GuardianRoster, FloorProgressBar,
} from './SpireSummaryTab.helpers';
// ─── Top Stats Row ───────────────────────────────────────────────────────────
function TopStatsRow({ maxFloorReached, totalFloorsCleared, defeatedCount, insight }: {
maxFloorReached: number;
totalFloorsCleared: number;
defeatedCount: number;
insight: number;
maxFloorReached: number; totalFloorsCleared: number; defeatedCount: number; insight: number;
}) {
return (
<Card className="bg-gray-900/60 border-gray-700">
@@ -129,17 +27,17 @@ function TopStatsRow({ maxFloorReached, totalFloorsCleared, defeatedCount, insig
<StatCell value={maxFloorReached} label="Max Floor Reached" color="text-amber-400" />
<StatCell value={totalFloorsCleared} label="Floors Cleared" color="text-gray-200" />
<StatCell value={defeatedCount} label="Guardians Defeated" color="text-emerald-400" />
<StatCell value={fmt(insight)} label="Insight Earned" color="text-purple-400" />
<StatCell value={insight} label="Insight Earned" color="text-purple-400" isFmt />
</div>
</CardContent>
</Card>
);
}
function StatCell({ value, label, color }: { value: number | string; label: string; color: string }) {
function StatCell({ value, label, color, isFmt }: { value: number; label: string; color: string; isFmt?: boolean }) {
return (
<div className="text-center">
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className={`text-2xl font-bold ${color}`}>{isFmt ? value : value}</div>
<div className="text-xs text-gray-400 mt-0.5">{label}</div>
</div>
);
@@ -147,16 +45,14 @@ function StatCell({ value, label, color }: { value: number | string; label: stri
// ─── Next Guardian Card ──────────────────────────────────────────────────────
function NextGuardianCard({ nextGuardian, nextGuardianData }: { nextGuardian: number; nextGuardianData: GuardianDef }) {
function NextGuardianCard({ nextGuardian, nextGuardianData }: { nextGuardian: number; nextGuardianData: ReturnType<typeof getGuardianForFloor> }) {
if (!nextGuardianData) return null;
const counterElement = getCounterElement(nextGuardianData.element[0]);
const nextFloorElement = FLOOR_ELEM_CYCLE[(nextGuardian - 1) % FLOOR_ELEM_CYCLE.length];
return (
<Card className="bg-gray-900/60 border-amber-800/40">
<SectionHeader
title={`🛡️ Next Guardian — Floor ${nextGuardian}`}
className="text-amber-400"
/>
<SectionHeader title={`🛡️ Next Guardian — Floor ${nextGuardian}`} className="text-amber-400" />
<CardContent className="pt-0 space-y-3">
<div className="flex items-center gap-3">
<div
@@ -171,204 +67,45 @@ function NextGuardianCard({ nextGuardian, nextGuardianData }: { nextGuardian: nu
</div>
<div>
<div className="font-semibold text-gray-100">{nextGuardianData.name}</div>
<div className="flex items-center gap-2 mt-0.5">
<Badge
variant="outline"
className="text-xs"
style={{ borderColor: getElementColor(nextGuardianData.element[0]), color: getElementColor(nextGuardianData.element[0]) }}
>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
<Badge variant="outline" className="text-xs" style={{ borderColor: getElementColor(nextGuardianData.element[0]), color: getElementColor(nextGuardianData.element[0]) }}>
{nextGuardianData.element.join(' + ')}
</Badge>
<span className="text-xs text-gray-500">Health: {fmt(nextGuardianData.hp)}</span>
{nextGuardianData.armor && (
<span className="text-xs text-gray-500">
Armor: {Math.round(nextGuardianData.armor * 100)}%
</span>
)}
{nextGuardianData.shield && nextGuardianData.shield > 0 && (
<span className="text-xs text-cyan-400">
Shield: {fmt(nextGuardianData.shield)}
</span>
)}
{nextGuardianData.barrier && nextGuardianData.barrier > 0 && (
<span className="text-xs text-blue-400">
Barrier: {Math.round(nextGuardianData.barrier * 100)}%
</span>
)}
{nextGuardianData.healthRegen && nextGuardianData.healthRegen > 0 && (
<span className="text-xs text-green-400">
Regen: {nextGuardianData.healthRegenIsPercent ? nextGuardianData.healthRegen + '%/tick' : nextGuardianData.healthRegen + '/tick'}
</span>
)}
<span className="text-xs text-gray-500">Health: {nextGuardianData.hp}</span>
{fmtArmor(nextGuardianData.armor)}
{fmtShield(nextGuardianData.shield)}
{fmtBarrier(nextGuardianData.barrier)}
{fmtRegen(nextGuardianData.healthRegen, nextGuardianData.healthRegenIsPercent)}
</div>
</div>
</div>
<PreparationTips
counterElement={counterElement}
nextFloorElement={nextFloorElement}
hasHighArmor={!!(nextGuardianData.armor && nextGuardianData.armor > 0.15)}
/>
<PreparationTips counterElement={counterElement} nextFloorElement={nextFloorElement} hasHighArmor={!!(nextGuardianData.armor && nextGuardianData.armor > 0.15)} />
</CardContent>
</Card>
);
}
function PreparationTips({ counterElement, nextFloorElement, hasHighArmor }: { counterElement: string | null; nextFloorElement: string | null; hasHighArmor: boolean }) {
return (
<div className="bg-gray-800/50 rounded-lg p-3 space-y-2">
<div className="text-xs font-medium text-gray-300">Recommended Preparation:</div>
<div className="text-xs text-gray-400 space-y-1">
{counterElement && (
<div className="flex items-center gap-2">
<span className="text-emerald-400"></span>
<span>
Use <span style={{ color: getElementColor(counterElement) }} className="font-medium">{counterElement}</span> spells for super effective damage (+50%)
</span>
</div>
)}
{nextFloorElement && (
<div className="flex items-center gap-2">
<span className="text-blue-400">🔄</span>
<span>
Floor element: <span style={{ color: getElementColor(nextFloorElement) }} className="font-medium">{nextFloorElement}</span>
</span>
</div>
)}
{hasHighArmor && (
<div className="flex items-center gap-2">
<span className="text-red-400">🛡</span>
<span>High armor consider armor-piercing or raw damage spells</span>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-amber-400">💡</span>
<span>Ensure mana pools are full before attempting</span>
</div>
</div>
</div>
);
}
// ─── Guardian Roster ─────────────────────────────────────────────────────────
function GuardianRoster({ clearedFloors }: { clearedFloors: Record<number, boolean> }) {
return (
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="🏛️ Guardian Roster" />
<CardContent className="pt-0">
<div className="space-y-2">
{GUARDIAN_FLOORS.map((floor) => {
const guardian = getGuardianForFloor(floor);
return guardian ? (
<GuardianRosterItem key={floor} floor={floor} guardian={guardian} isDefeated={!!clearedFloors[floor]} />
) : null;
})}
</div>
</CardContent>
</Card>
);
}
function GuardianRosterItem({ floor, guardian, isDefeated }: { floor: number; guardian: GuardianDef; isDefeated: boolean }) {
return (
<div
className={`flex items-center justify-between p-2 rounded border ${
isDefeated
? 'bg-emerald-900/20 border-emerald-800/40'
: 'bg-gray-800/40 border-gray-700/50'
}`}
>
<div className="flex items-center gap-2">
<div
className="w-7 h-7 rounded flex items-center justify-center text-xs font-bold"
style={{
backgroundColor: isDefeated ? `${guardian.color}30` : '#374151',
color: isDefeated ? guardian.color : '#6B7280',
}}
>
{floor}
</div>
<div>
<div className={`text-sm font-medium ${isDefeated ? 'text-gray-100' : 'text-gray-400'}`}>
{guardian.name}
</div>
<div className="flex items-center gap-1.5">
<span
className="text-[10px] px-1.5 py-0.5 rounded"
style={{
backgroundColor: `${guardian.color}15`,
color: guardian.color,
}}
>
{guardian.element.join(' + ')}
</span>
<span className="text-[10px] text-gray-500">Health: {fmt(guardian.hp)}</span>
</div>
</div>
</div>
<div>
{isDefeated ? (
<Badge variant="outline" className="border-emerald-600 text-emerald-400 text-xs">
Defeated
</Badge>
) : (
<Badge variant="outline" className="border-gray-600 text-gray-500 text-xs">
Undefeated
</Badge>
)}
</div>
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export function SpireSummaryTab() {
const {
maxFloorReached,
clearedFloors,
enterSpireMode,
} = useCombatStore(useShallow((s) => ({
const { maxFloorReached, clearedFloors, enterSpireMode } = useCombatStore(useShallow((s) => ({
maxFloorReached: s.maxFloorReached,
clearedFloors: s.clearedFloors,
enterSpireMode: s.enterSpireMode,
})));
const { insight } = usePrestigeStore(useShallow((s) => ({ insight: s.insight })));
const { insight } = usePrestigeStore(useShallow((s) => ({
insight: s.insight,
})));
const defeatedGuardians = useMemo(() => {
return GUARDIAN_FLOORS.filter((floor) => clearedFloors[floor]);
}, [clearedFloors]);
const nextGuardian = useMemo(() => {
return GUARDIAN_FLOORS.find((floor) => !clearedFloors[floor]) || null;
}, [clearedFloors]);
const defeatedGuardians = useMemo(() => getAllGuardianFloors().filter((floor) => clearedFloors[floor]), [clearedFloors]);
const nextGuardian = useMemo(() => getAllGuardianFloors().find((floor) => !clearedFloors[floor]) || null, [clearedFloors]);
const nextGuardianData = nextGuardian ? getGuardianForFloor(nextGuardian) : null;
const totalFloorsCleared = useMemo(() => {
return Object.values(clearedFloors).filter(Boolean).length;
}, [clearedFloors]);
const totalFloorsCleared = useMemo(() => Object.values(clearedFloors).filter(Boolean).length, [clearedFloors]);
return (
<DebugName name="SpireSummaryTab">
<div className="space-y-4">
<TopStatsRow
maxFloorReached={maxFloorReached}
totalFloorsCleared={totalFloorsCleared}
defeatedCount={defeatedGuardians.length}
insight={insight}
/>
{nextGuardianData && nextGuardian && (
<NextGuardianCard nextGuardian={nextGuardian} nextGuardianData={nextGuardianData} />
)}
<TopStatsRow maxFloorReached={maxFloorReached} totalFloorsCleared={totalFloorsCleared} defeatedCount={defeatedGuardians.length} insight={insight} />
{nextGuardianData && nextGuardian && <NextGuardianCard nextGuardian={nextGuardian} nextGuardianData={nextGuardianData} />}
<GuardianRoster clearedFloors={clearedFloors} />
<Card className="bg-gray-900/60 border-gray-700">
<SectionHeader title="🗺️ Floor Progress" />
<CardContent className="pt-0">