Release Healer Man 0.1.39

This commit is contained in:
phenom committed 2026-09-13 11:07:56 -04:00
1 parent b417b7c3c3
commit ed0d2fea3d
51 files changed
+17811 -49

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
import { DUNGEON_LEADERBOARD_PAGE_SIZE, type LeaderboardScope } from "../app/dungeonLeaderboardClient";
import { formatDungeonDuration, type DungeonClearFilter, type DungeonLeaderboard as LeaderboardData } from "../game/dungeonRunTypes";
import { ControllerButton } from "./ControllerButton";
import { DungeonRunDetails } from "./DungeonRunDetails";
import "./dungeonRuns.css";
export function DungeonLeaderboard({ dungeonTitle, data, scope, loading, filter, onFilter,
expanded, onExpand, offset, onPage, onRefresh, difficultyLabel, onDifficulty,
selectedId, select }: {
readonly dungeonTitle: string;
readonly data: LeaderboardData;
readonly scope: LeaderboardScope;
readonly loading: boolean;
readonly filter: DungeonClearFilter;
readonly onFilter: (filter: DungeonClearFilter) => void;
readonly expanded: ReadonlySet<string>;
readonly onExpand: (id: string) => void;
readonly offset: number;
readonly onPage: (offset: number) => void;
readonly onRefresh: () => void;
readonly difficultyLabel: string;
readonly onDifficulty?: () => void;
readonly selectedId: string;
readonly select: (id: string) => void;
}) {
const controls = { selectedId, select };
return <section className="dungeon-leaderboard" aria-label={`${dungeonTitle} leaderboard`} aria-busy={loading}>
<header>
<p className="front-kicker">{scope === "world" ? "World leaderboard" : "This device · offline leaderboard"}</p>
<h3>Fastest expeditions</h3>
<div className="dungeon-leaderboard__filters" aria-label="Clear type">
<ControllerButton {...controls} controlId="leaderboard-final" aria-pressed={filter === "final"}
onClick={() => onFilter("final")}>Final Boss clears <small>All entries</small></ControllerButton>
<ControllerButton {...controls} controlId="leaderboard-full" aria-pressed={filter === "full"}
onClick={() => onFilter("full")}>Full dungeon clears <small>All bosses</small></ControllerButton>
</div>
<div className="dungeon-leaderboard__meta">
{onDifficulty ? <ControllerButton {...controls} controlId="leaderboard-difficulty" onClick={onDifficulty}
aria-label={`Leaderboard difficulty: ${difficultyLabel}. Change difficulty`}>{difficultyLabel} ↻</ControllerButton>
: <span>{difficultyLabel}</span>}
<span>{data.total} {data.total === 1 ? "run" : "runs"}</span>
<ControllerButton {...controls} controlId="leaderboard-refresh" onClick={onRefresh}>Refresh</ControllerButton>
</div>
</header>
<div className="dungeon-leaderboard__entries">
{loading ? <p className="dungeon-leaderboard__empty" role="status">Loading run times…</p>
: data.entries.length === 0 ? <p className="dungeon-leaderboard__empty">No {filter === "full" ? "full dungeon" : "final boss"} clears yet. Complete a run to set the first time.</p>
: <ol>
{data.entries.map((entry) => <li key={entry.id}>
<ControllerButton {...controls} controlId={`leaderboard-run-${entry.id}`}
className="dungeon-leaderboard__run" aria-expanded={expanded.has(entry.id)}
aria-controls={`run-details-${entry.id}`} onClick={() => onExpand(entry.id)}>
<b>#{entry.rank}</b><span><strong>{entry.party.find((member) => !member.npc)?.name ?? entry.party[0]?.name ?? "Party"}</strong>
<small>{entry.fullClear ? "All bosses" : `${entry.killedBossIds.length} bosses`} · {new Date(entry.finishedAt).toLocaleDateString()}</small></span>
<time>{formatDungeonDuration(entry.durationMs)}</time><em aria-hidden="true">{expanded.has(entry.id) ? "−" : "+"}</em>
</ControllerButton>
{expanded.has(entry.id) ? <div id={`run-details-${entry.id}`}><DungeonRunDetails dungeonId={entry.dungeonId}
difficultyId={entry.difficultyId} party={entry.party} killedBossIds={entry.killedBossIds} /></div> : null}
</li>)}
</ol>}
</div>
{data.total > DUNGEON_LEADERBOARD_PAGE_SIZE ? <nav className="dungeon-leaderboard__pages" aria-label="Leaderboard pages">
<ControllerButton {...controls} controlId="leaderboard-prev" disabled={offset === 0 || loading}
onClick={() => onPage(Math.max(0, offset - DUNGEON_LEADERBOARD_PAGE_SIZE))}>Previous</ControllerButton>
<span>{offset + 1}–{Math.min(offset + DUNGEON_LEADERBOARD_PAGE_SIZE, data.total)} of {data.total}</span>
<ControllerButton {...controls} controlId="leaderboard-next" disabled={offset + DUNGEON_LEADERBOARD_PAGE_SIZE >= data.total || loading}
onClick={() => onPage(offset + DUNGEON_LEADERBOARD_PAGE_SIZE)}>Next</ControllerButton>
</nav> : null}
</section>;
}
+32
View File
@@ -0,0 +1,32 @@
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
import type { DungeonRunMember } from "../game/dungeonRunTypes";
const className = (id: string) => catalog.classes.find((entry) => entry.id === id)?.name ?? id;
export function DungeonRunDetails({ dungeonId, difficultyId, party, killedBossIds }: {
readonly dungeonId: string;
readonly difficultyId: string;
readonly party: readonly DungeonRunMember[];
readonly killedBossIds: readonly string[];
}) {
const dungeon = catalog.dungeons.find((entry) => entry.id === dungeonId);
const ids = dungeon?.difficulties.find((entry) => entry.id === difficultyId)?.bossIds ?? [];
const bosses = dungeon?.bosses.filter((boss) => ids.includes(boss.id)) ?? [];
return <div className="dungeon-run-details">
<h4>Party</h4>
<ul className="dungeon-run-party">
{party.map((member, index) => <li key={`${member.name}-${index}`}>
<strong>{member.name}{member.npc ? <small> NPC</small> : null}</strong>
<span>Level {member.level} · {className(member.classId)}{member.secondaryClassId ? ` / ${className(member.secondaryClassId)}` : ""}</span>
<small>{member.role === "damage" ? "Damage" : member.role === "healer" ? "Healer" : "Tank"}</small>
</li>)}
</ul>
<h4>Bosses defeated · {killedBossIds.length}/{bosses.length}</h4>
<ul className="dungeon-run-bosses">
{bosses.map((boss) => <li key={boss.id} className={killedBossIds.includes(boss.id) ? "is-defeated" : ""}>
<span aria-hidden="true">{killedBossIds.includes(boss.id) ? "✓" : "—"}</span>
{boss.name}<small>{killedBossIds.includes(boss.id) ? "Defeated" : "Not defeated"}</small>
</li>)}
</ul>
</div>;
}
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useShellStore } from "../app/shellStore";
import { awaitSharedDungeonRunPlacement, localDungeonRunPlacement, saveLocalDungeonRun, submitDungeonRun, type LeaderboardScope } from "../app/dungeonLeaderboardClient";
import { useOnlineSessionStore } from "../app/onlineSessionStore";
import { completedDungeonRun, useDungeonRunStore } from "../game/dungeonRunStore";
import { formatDungeonDuration, type DungeonRunPlacement, type DungeonRunProgress } from "../game/dungeonRunTypes";
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
import { useGameStore } from "../game/store";
import { useMenuController } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
import { DungeonRunDetails } from "./DungeonRunDetails";
import "./dungeonRuns.css";
export interface RunPlacementState {
readonly scope: LeaderboardScope;
readonly placement: DungeonRunPlacement | null;
readonly loading: boolean;
}
export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, onRetry }: {
readonly progress: DungeonRunProgress;
readonly ranking: RunPlacementState;
readonly onFinish: () => void;
readonly onLoot: () => void;
readonly onRetry: () => void;
}) {
const definition = catalog.dungeons.find((dungeon) => dungeon.id === progress.dungeonId);
const fullClear = definition?.difficulties.find((entry) => entry.id === progress.difficultyId)?.bossIds
.every((id) => progress.killedBossIds.includes(id)) ?? false;
const controller = useMenuController([
{ id: "run-loot", run: onLoot }, { id: "run-finish", run: onFinish },
...(ranking.scope === "device" ? [{ id: "run-retry", run: onRetry }] : []),
], { initialId: "run-loot", onBack: onLoot });
const controls = { selectedId: controller.selectedId, select: controller.select };
const panel = useRef<HTMLElement>(null);
useEffect(() => {
const previous = document.activeElement;
panel.current?.focus();
return () => { if (previous instanceof HTMLElement && previous.isConnected) previous.focus(); };
}, []);
return <div className="modal-backdrop dungeon-results-backdrop">
<section ref={panel} tabIndex={-1} className="dungeon-results" role="dialog" aria-modal="true"
aria-labelledby="dungeon-results-title" onKeyDown={(event) => {
if (event.key !== "Tab") return;
const buttons = Array.from(panel.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)") ?? []);
const first = buttons[0]; const last = buttons.at(-1);
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel.current)) {
event.preventDefault(); last?.focus();
} else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
}}>
<header><p className="front-kicker">{fullClear ? "Full dungeon clear" : "Final boss clear"}</p>
<h2 id="dungeon-results-title">{definition?.title ?? progress.dungeonId} complete</h2>
<p>The final boss is defeated. Your expedition is complete.</p>
</header>
<div className="dungeon-results__score">
<div><small>Time to final boss</small><strong>{progress.timed && progress.durationMs !== null
? formatDungeonDuration(progress.durationMs) : "Untimed"}</strong></div>
<div aria-live="polite"><small>{ranking.scope === "world" ? "World leaderboard" : "This device leaderboard"}</small>
<strong>{ranking.loading ? "Recording…" : ranking.placement ? `#${ranking.placement.finalRank}` : "Unranked"}</strong>
{ranking.placement ? <span>of {ranking.placement.finalTotal} final boss clears</span> : null}
</div>
</div>
{ranking.placement?.fullRank ? <p className="dungeon-results__full-rank">Full dungeon clears: <strong>#{ranking.placement.fullRank}</strong> of {ranking.placement.fullTotal}</p> : null}
{!progress.timed ? <p className="dungeon-results__note">This resumed run has no saved timer. Start a new expedition to record a leaderboard time.</p> : null}
{ranking.scope === "device" ? <p className="dungeon-results__note">Your time is saved on this device. Reconnect to submit it to the world leaderboard.</p> : null}
<div className="dungeon-results__details"><DungeonRunDetails dungeonId={progress.dungeonId}
difficultyId={progress.difficultyId} party={progress.party} killedBossIds={progress.killedBossIds} /></div>
<footer>
<ControllerButton {...controls} controlId="run-loot" className="button" onClick={onLoot}>Stay and loot</ControllerButton>
<ControllerButton {...controls} controlId="run-finish" className="button button--primary" onClick={onFinish}>Return to main menu</ControllerButton>
{ranking.scope === "device" ? <ControllerButton {...controls} controlId="run-retry" className="button" onClick={onRetry}>Retry submission</ControllerButton> : null}
</footer>
</section>
</div>;
}
export function DungeonRunResults() {
const progress = useDungeonRunStore((state) => state.progress);
const overlay = useGameStore((state) => state.overlay);
const record = useMemo(() => progress ? completedDungeonRun(progress) : null, [progress]);
const [retry, setRetry] = useState(0);
const [ranking, setRanking] = useState<RunPlacementState>({ scope: "world", placement: null, loading: false });
useEffect(() => {
if (!record) { setRanking({ scope: "world", placement: null, loading: false }); return; }
let live = true;
saveLocalDungeonRun(record);
setRanking({ scope: "world", placement: null, loading: true });
const online = useOnlineSessionStore.getState().snapshot;
const submission = online?.activity.id === record.id && !online.authority
? awaitSharedDungeonRunPlacement(record.id) : submitDungeonRun(record, useShellStore.getState().session);
void submission
.then((placement) => { if (live) setRanking({ scope: "world", placement, loading: false }); })
.catch(() => { if (live) setRanking({ scope: "device", placement: localDungeonRunPlacement(record), loading: false }); });
return () => { live = false; };
}, [record, retry]);
if (overlay !== "dungeon-results" || !progress || progress.finishedAt === null) return null;
return <DungeonRunResultsDialog progress={progress} ranking={ranking} onRetry={() => setRetry((value) => value + 1)}
onLoot={() => useGameStore.getState().closeOverlay()} onFinish={() => {
useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
useGameStore.getState().closeOverlay();
useShellStore.getState().returnToMainMenu();
}} />;
}
+35 -14
View File
@@ -21,7 +21,10 @@ import {
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton";
import { DungeonMapGraphic } from "./DungeonMapGraphic";
import { DungeonLeaderboard } from "./DungeonLeaderboard";
import { useDungeonLeaderboard } from "./useDungeonLeaderboard";
import type { DungeonClearFilter } from "../game/dungeonRunTypes";
import { DUNGEON_LEADERBOARD_PAGE_SIZE } from "../app/dungeonLeaderboardClient";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
@@ -77,6 +80,20 @@ export function DungeonSelectScreen() {
?? firstCategoryDungeon
?? null;
const selectedDefinition = selectedDungeon ? dungeonDefinitionById(selectedDungeon.id) : null;
const [clearFilter, setClearFilter] = useState<DungeonClearFilter>("final");
const [difficultyId, setDifficultyId] = useState("");
const [expandedRuns, setExpandedRuns] = useState<ReadonlySet<string>>(new Set());
const leaderboardDifficulty = selectedDefinition?.difficultyVariants.find((variant) => variant.id === difficultyId)
?? selectedDefinition?.difficultyVariants.find((variant) => variant.id === selectedDefinition.defaultDifficultyId);
const leaderboard = useDungeonLeaderboard(selectedDungeon?.id ?? "", leaderboardDifficulty?.id ?? "normal", clearFilter);
const toggleRun = (id: string) => setExpandedRuns((current) => {
const next = new Set(current); if (next.has(id)) next.delete(id); else next.add(id); return next;
});
const cycleDifficulty = () => {
const variants = selectedDefinition?.difficultyVariants ?? [];
const index = variants.findIndex((variant) => variant.id === leaderboardDifficulty?.id);
if (variants.length) setDifficultyId(variants[(index + 1) % variants.length].id);
};
const selectedPackInstalled = selectedDungeon ? dungeonContentIsUsable(selectedDungeon.id) : false;
const canStart = Boolean(selectedDungeon?.available && selectedPackInstalled);
@@ -176,8 +193,17 @@ export function DungeonSelectScreen() {
id: `dungeon-${dungeon.id}`,
run: () => selectDungeon(dungeon.id),
enabled: true,
neighbors: index === 0 ? { up: `dungeon-category-${selectedCategory}` } : undefined,
neighbors: { ...(index === 0 ? { up: `dungeon-category-${selectedCategory}` } : {}), right: "leaderboard-final" },
})),
{ id: "leaderboard-final", run: () => setClearFilter("final") },
{ id: "leaderboard-full", run: () => setClearFilter("full") },
{ id: "leaderboard-difficulty", run: cycleDifficulty, enabled: (selectedDefinition?.difficultyVariants.length ?? 0) > 1 },
{ id: "leaderboard-refresh", run: leaderboard.refresh },
...leaderboard.data.entries.map((entry) => ({ id: `leaderboard-run-${entry.id}`, run: () => toggleRun(entry.id) })),
{ id: "leaderboard-prev", run: () => leaderboard.setOffset(Math.max(0, leaderboard.offset - DUNGEON_LEADERBOARD_PAGE_SIZE)),
enabled: !leaderboard.loading && leaderboard.offset > 0 },
{ id: "leaderboard-next", run: () => leaderboard.setOffset(leaderboard.offset + DUNGEON_LEADERBOARD_PAGE_SIZE),
enabled: !leaderboard.loading && leaderboard.offset + DUNGEON_LEADERBOARD_PAGE_SIZE < leaderboard.data.total },
...(rolePickerOpen
? availableRoles.map((role) => ({
id: `dungeon-role-${role}`,
@@ -193,7 +219,7 @@ export function DungeonSelectScreen() {
run: () => setRolePickerOpen(false),
}] : []),
{ id: "dungeon-back", run: returnToMainMenu },
], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon]);
], [availableRoles, categoryDungeons, downloading, firstCategoryDungeon, pendingDownload, returnToMainMenu, rolePickerOpen, selectedCategory, selectedDungeon, selectedDefinition, leaderboard, expandedRuns, leaderboardDifficulty]);
const controller = useMenuController(actions, {
initialId: firstAvailable ? `dungeon-${firstAvailable.id}` : "dungeon-back",
onBack: pendingDownload
@@ -289,17 +315,12 @@ export function DungeonSelectScreen() {
<main className="dungeon-select-detail" aria-live="polite">
{selectedDungeon ? (
<>
<div className="dungeon-select-map">
{selectedDefinition ? (
<DungeonMapGraphic
dungeonId={selectedDefinition.id}
position={selectedDefinition.entrance.footPosition}
yaw={selectedDefinition.entrance.yaw}
/>
) : (
<div className="dungeon-select-map__placeholder" aria-hidden="true">HM</div>
)}
</div>
<DungeonLeaderboard dungeonTitle={selectedDungeon.title} data={leaderboard.data} scope={leaderboard.scope}
loading={leaderboard.loading} filter={clearFilter} onFilter={setClearFilter}
expanded={expandedRuns} onExpand={toggleRun} offset={leaderboard.offset} onPage={leaderboard.setOffset}
onRefresh={leaderboard.refresh} difficultyLabel={leaderboardDifficulty?.difficulty === "heroic" ? "Heroic" : "Normal"}
onDifficulty={(selectedDefinition?.difficultyVariants.length ?? 0) > 1 ? cycleDifficulty : undefined}
selectedId={controller.selectedId} select={controller.select} />
<section className="dungeon-select-copy">
<p className="front-kicker">{canStart ? "Playable expedition" : selectedDungeon.available ? "Content required" : "In development"}</p>
<h2>{selectedDungeon.title}</h2>
+18 -5
View File
@@ -37,6 +37,8 @@ import { ThreatMeterWidget } from "./ThreatMeterWidget";
import { BossLootWindow } from "./BossLootWindow";
import { EditableHudElement } from "./EditableHudElement";
import { DpsMeter } from "./DpsMeter";
import { useDungeonRunStore } from "../game/dungeonRunStore";
import { formatDungeonDuration } from "../game/dungeonRunTypes";
import { DesktopActionBars } from "./DesktopActionBars";
import { currentUiDevice, useUiLayoutStore } from "../game/uiLayoutStore";
import { SummonFrames } from "./SummonFrames";
@@ -140,20 +142,28 @@ export function DefeatedPrompt({
export function DungeonCompletePrompt({
dungeonTitle,
onFinish,
onResults,
durationMs,
finalBossOnly = false,
}: {
readonly dungeonTitle: string;
readonly onFinish: () => void;
readonly onResults?: () => void;
readonly durationMs?: number | null;
readonly finalBossOnly?: boolean;
}) {
return (
<section className="dungeon-complete-banner" role="status" aria-live="polite">
<span aria-hidden="true">◆</span>
<p>
<strong>{dungeonTitle} complete</strong>
<small>Every required encounter is defeated. Loot the room or finish the run.</small>
<small>{finalBossOnly ? "Final boss defeated." : "Every required encounter is defeated."} Loot the room or finish the run.</small>
{durationMs != null ? <small>Time to final boss: {formatDungeonDuration(durationMs)}</small> : null}
</p>
<button type="button" className="button button--primary" onClick={onFinish}>
Finish dungeon
</button>
{onResults ? <button type="button" className="button" onClick={onResults}>Run results</button> : null}
</section>
);
}
@@ -218,8 +228,9 @@ export function Hud() {
activeDungeonEncounterRevision,
activeDungeonEncounterRevision,
);
const dungeonComplete = gameMode === "dungeon"
&& activeDungeonRunIsComplete(activeDungeonId);
const runProgress = useDungeonRunStore((state) => state.progress);
const finalBossDefeated = runProgress?.dungeonId === activeDungeonId && runProgress.finishedAt !== null;
const dungeonComplete = gameMode === "dungeon" && (finalBossDefeated || activeDungeonRunIsComplete(activeDungeonId));
useEffect(() => {
const timer = window.setInterval(() => setClock(Date.now()), 100);
@@ -384,8 +395,10 @@ export function Hud() {
<ShelvedHud />
<DpsMeter />
<BossLootWindow />
<EditableHudElement id="completion">{dungeonComplete && health > 0 ? (
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon} />
<EditableHudElement id="completion">{dungeonComplete ? (
<DungeonCompletePrompt dungeonTitle={dungeon.title} onFinish={finishDungeon}
finalBossOnly={!activeDungeonRunIsComplete(activeDungeonId)} durationMs={runProgress?.timed ? runProgress.durationMs : null}
onResults={finalBossDefeated ? () => useGameStore.getState().openOverlay("dungeon-results") : undefined} />
) : null}</EditableHudElement>
<EditableHudElement id="loot">{feedback?.kind === "loot" ? (
<section className="loot-reward" role="status" aria-live="polite">
+102
View File
@@ -0,0 +1,102 @@
.dungeon-leaderboard, .dungeon-results {
--run-gold: var(--front-gold, #d8ae57);
--run-green: var(--front-green-bright, #c8e28d);
--run-line: var(--front-line, rgba(204, 224, 180, .18));
--run-muted: #b1b7a3;
color: #f0ecdc;
}
.dungeon-leaderboard {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--run-line);
border-radius: 13px 4px 13px 4px;
background: rgba(4, 16, 11, .9);
}
.dungeon-leaderboard > header { padding: 15px 12px 10px; border-bottom: 1px solid var(--run-line); }
.dungeon-leaderboard h3 { margin: 6px 0 14px; color: var(--run-gold); font: 24px Georgia, serif; }
.dungeon-leaderboard__filters { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.dungeon-leaderboard button { color: inherit; cursor: pointer; }
.dungeon-leaderboard__filters button {
min-height: 48px;
padding: 6px;
border: 1px solid var(--run-line);
border-radius: 5px;
background: #0c1a12;
font-size: 11px;
}
.dungeon-leaderboard__filters button[aria-pressed="true"] { border-color: var(--run-gold); background: #27301a; color: #f2dfaa; }
.dungeon-leaderboard__filters small { display: block; margin-top: 3px; color: var(--run-muted); font-size: 9px; }
.dungeon-leaderboard__meta { display: flex; margin-top: 10px; align-items: center; justify-content: space-between; gap: 7px; color: var(--run-muted); font-size: 10px; }
.dungeon-leaderboard__meta button { padding: 5px 2px; border: 0; background: transparent; color: var(--run-green); font-size: 10px; }
.dungeon-leaderboard__entries { min-height: 0; flex: 1; overflow: auto; overscroll-behavior: contain; }
.dungeon-leaderboard__entries ol { margin: 0; padding: 0; list-style: none; }
.dungeon-leaderboard__entries > ol > li { border-bottom: 1px solid var(--run-line); }
.dungeon-leaderboard__run { display: grid; width: 100%; min-height: 63px; grid-template-columns: 31px minmax(0, 1fr) auto 10px; gap: 7px; padding: 10px; align-items: center; border: 0; background: transparent; text-align: left; }
.dungeon-leaderboard__run:hover, .dungeon-leaderboard__run[aria-expanded="true"] { background: rgba(143, 181, 92, .09); }
.dungeon-leaderboard__run > b { color: var(--run-gold); font: 16px Georgia, serif; }
.dungeon-leaderboard__run > span { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.dungeon-leaderboard__run strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.dungeon-leaderboard__run small { color: var(--run-muted); font-size: 9px; }
.dungeon-leaderboard__run time { color: var(--run-green); font-size: 14px; font-variant-numeric: tabular-nums; }
.dungeon-leaderboard__run em { color: var(--run-gold); font-size: 16px; font-style: normal; }
.dungeon-leaderboard__empty { padding: 22px 15px; color: var(--run-muted); font-size: 12px; line-height: 1.7; }
.dungeon-leaderboard__pages { display: flex; padding: 8px; align-items: center; justify-content: space-between; gap: 5px; border-top: 1px solid var(--run-line); font-size: 9px; }
.dungeon-leaderboard__pages button { padding: 7px; border: 1px solid var(--run-line); border-radius: 4px; background: #122019; font-size: 10px; }
.dungeon-leaderboard button:disabled { opacity: .4; cursor: default; }
.dungeon-run-details { padding: 8px 12px 12px; }
.dungeon-run-details h4 { margin: 9px 0; color: var(--run-gold); font-size: 11px; font-weight: 500; }
.dungeon-run-details ul { margin: 0; padding: 0; list-style: none; }
.dungeon-run-party li { display: flex; padding: 6px 0; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 9px; border-bottom: 1px solid rgba(204, 224, 180, .07); }
.dungeon-run-party strong { font-size: 11px; }
.dungeon-run-party strong small { color: var(--run-muted); font-size: 8px; font-weight: 400; }
.dungeon-run-party span { width: 100%; order: 3; color: #d6dcc9; font-size: 10px; }
.dungeon-run-party li > small { color: var(--run-muted); font-size: 9px; }
.dungeon-run-bosses li { display: flex; padding: 5px 0; align-items: center; gap: 7px; color: var(--run-muted); font-size: 10px; }
.dungeon-run-bosses li.is-defeated { color: var(--run-green); }
.dungeon-run-bosses small { margin-left: auto; color: var(--run-muted); font-size: 8px; }
.dungeon-results-backdrop { z-index: 110; padding: 16px; pointer-events: auto; }
.dungeon-results {
display: flex;
width: min(580px, calc(100vw - 32px));
max-height: calc(100dvh - 32px);
flex-direction: column;
padding: 23px;
border: 1px solid var(--run-gold);
border-radius: 12px;
outline: 0;
background: linear-gradient(145deg, #172319, #07100d);
box-shadow: 0 20px 65px #0009;
}
.dungeon-results > header { flex: 0 0 auto; }
.dungeon-results h2 { margin: 7px 0; color: #f3deb0; font: 29px Georgia, serif; }
.dungeon-results header > p:last-child { margin: 9px 0 16px; color: var(--run-muted); font-size: 12px; }
.dungeon-results__score { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; flex: 0 0 auto; }
.dungeon-results__score > div { display: flex; padding: 14px; flex-direction: column; gap: 6px; border: 1px solid var(--run-line); border-radius: 6px; background: #ffffff04; }
.dungeon-results__score small { color: var(--run-muted); font-size: 11px; }
.dungeon-results__score strong { color: var(--run-green); font: 31px Georgia, serif; font-variant-numeric: tabular-nums; }
.dungeon-results__score span { color: var(--run-muted); font-size: 10px; }
.dungeon-results__full-rank { margin: 12px 0 0; color: var(--run-gold); font-size: 12px; }
.dungeon-results__note { margin: 12px 0 0; color: var(--run-muted); font-size: 11px; line-height: 1.5; }
.dungeon-results__details { min-height: 40px; margin-top: 13px; overflow: auto; overscroll-behavior: contain; border-block: 1px solid var(--run-line); }
.dungeon-results__details .dungeon-run-details { padding-inline: 0; }
.dungeon-results > footer { display: flex; margin-top: 16px; flex: 0 0 auto; flex-wrap: wrap; gap: 8px; }
.dungeon-results > footer button { min-height: 38px; flex: 1; white-space: nowrap; }
@media (max-width: 960px) {
.dungeon-select-detail { grid-template-rows: minmax(145px, 1fr) minmax(120px, .8fr); gap: 9px; }
.dungeon-leaderboard > header { padding: 9px; }
.dungeon-leaderboard h3 { margin: 4px 0 8px; font-size: 19px; }
.dungeon-leaderboard__filters button { min-height: 39px; font-size: 10px; }
.dungeon-leaderboard__meta { margin-top: 5px; }
.dungeon-leaderboard__run { min-height: 52px; }
}
@media (max-height: 520px) {
.dungeon-results { padding: 13px; }
.dungeon-results h2 { font-size: 23px; }
.dungeon-results header > p:last-child { margin-bottom: 9px; }
.dungeon-results__score > div { padding: 8px 12px; }
.dungeon-results__score strong { font-size: 25px; }
.dungeon-results > footer { margin-top: 9px; }
}
+39
View File
@@ -0,0 +1,39 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { DungeonLeaderboard } from "./DungeonLeaderboard";
import { DungeonRunResultsDialog } from "./DungeonRunResults";
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
import type { DungeonRunProgress } from "../game/dungeonRunTypes";
const caves = catalog.dungeons.find((entry) => entry.id === "barren-caves")!;
const progress: DungeonRunProgress = { id: "ui-caves-test", dungeonId: caves.id, difficultyId: "normal", timed: true,
startedAt: 1000, finishedAt: 13345, durationMs: 12345, killedBossIds: [caves.finalBossId],
party: [{ name: "Tester", classId: "rom-priest", level: 3, role: "healer", npc: false },
{ name: "Garrick", classId: "warrior", level: 3, role: "tank", npc: true }] };
const noop = () => undefined;
describe("dungeon results and leaderboard", () => {
it("announces the final boss, frozen time, both rankings, party levels, and explicit loot/menu choices", () => {
const html = renderToStaticMarkup(createElement(DungeonRunResultsDialog, { progress,
ranking: { scope: "world", loading: false, placement: { finalRank: 2, finalTotal: 10, fullRank: 1, fullTotal: 4 } },
onFinish: noop, onLoot: noop, onRetry: noop }));
for (const text of ["Barren Caves complete", "00:12.3", "#2", "#1", "Anglo", "Level 3", "Garrick", "Stay and loot", "Return to main menu"])
expect(html).toContain(text);
expect(html).toContain('aria-modal="true"');
});
it("labels disconnected rankings as device-only and offers submission retry", () => {
const html = renderToStaticMarkup(createElement(DungeonRunResultsDialog, { progress,
ranking: { scope: "device", loading: false, placement: { finalRank: 1, finalTotal: 1, fullRank: 1, fullTotal: 1 } },
onFinish: noop, onLoot: noop, onRetry: noop }));
expect(html).toContain("This device leaderboard"); expect(html).toContain("Retry submission");
});
it("offers both clear filters and expandable party/boss records in the dungeon picker", () => {
const html = renderToStaticMarkup(createElement(DungeonLeaderboard, { dungeonTitle: caves.title,
data: { entries: [{ ...progress, durationMs: progress.durationMs!, finishedAt: progress.finishedAt!, fullClear: true, rank: 1 }], total: 1 },
scope: "world", loading: false, filter: "full", onFilter: noop, expanded: new Set([progress.id]),
onExpand: noop, offset: 0, onPage: noop, onRefresh: noop, difficultyLabel: "Normal", selectedId: "", select: noop }));
for (const text of ["Final Boss clears", "All entries", "Full dungeon clears", "All bosses", "Anglo", "Level 3", "00:12.3"])
expect(html).toContain(text);
expect(html).toContain('aria-expanded="true"');
});
});
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useState } from "react";
import { fetchDungeonLeaderboard, localDungeonLeaderboard, subscribeDungeonLeaderboard,
type LeaderboardScope } from "../app/dungeonLeaderboardClient";
import type { DungeonClearFilter, DungeonLeaderboard } from "../game/dungeonRunTypes";
export function useDungeonLeaderboard(dungeonId: string, difficultyId: string, filter: DungeonClearFilter) {
const [offset, setOffset] = useState(0);
const [revision, setRevision] = useState(0);
const [state, setState] = useState<{ data: DungeonLeaderboard; scope: LeaderboardScope; loading: boolean }>({
data: { entries: [], total: 0 }, scope: "world", loading: true,
});
useEffect(() => { setOffset(0); }, [dungeonId, difficultyId, filter]);
useEffect(() => subscribeDungeonLeaderboard(() => setRevision((value) => value + 1)), []);
useEffect(() => {
const refresh = () => setRevision((value) => value + 1);
window.addEventListener("focus", refresh);
return () => window.removeEventListener("focus", refresh);
}, []);
useEffect(() => {
const controller = new AbortController();
let live = true;
const timeout = setTimeout(() => controller.abort(), 8000);
setState({ data: { entries: [], total: 0 }, scope: "world", loading: true });
if (dungeonId) void fetchDungeonLeaderboard(dungeonId, difficultyId, filter, offset, controller.signal)
.then((data) => { if (live) setState({ data, scope: "world", loading: false }); })
.catch(() => { if (live) setState({ data: localDungeonLeaderboard(dungeonId, difficultyId, filter, offset),
scope: "device", loading: false }); });
else setState({ data: { entries: [], total: 0 }, scope: "world", loading: false });
return () => { live = false; controller.abort(); clearTimeout(timeout); };
}, [dungeonId, difficultyId, filter, offset, revision]);
return { ...state, offset, setOffset, refresh: () => setRevision((value) => value + 1) };
}