fix: bugs #238,#240,#244,#246 + docs #248 update
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m21s
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user