Update Healer Man server

This commit is contained in:
phenom committed 2026-09-26 17:51:56 -04:00
1 parent 9c5fb4c614
commit bd073fb00f
113 files changed
+4902 -273

No files matched your search

+10 -4
View File
@@ -30,11 +30,17 @@ describe("dungeon map artwork", () => {
it("shows explicitly selected floor artwork", () => {
expect(render("deadmines", { floorLevel: 2 })).toContain("deadmines/maps/floor-2.png");
});
it("preserves the survey fallback for RuneWaker dungeons", () => {
it("shows original RuneWaker artwork with live markers", () => {
const html = render("forsaken-abbey");
expect(html).toContain('viewBox="0 0 430 300"');
expect(html).not.toContain("<image");
expect(html).toContain('data-artwork="unavailable"');
expect(html).toContain('viewBox="0 0 1002 668"');
expect(html).toContain("forsaken-abbey/maps/runewaker-map.png");
expect(html).toContain("map-player");
expect(html).toContain("Dungeon entrance");
});
it("uses a calibrated navigation floorplan when the client has no usable artwork", () => {
const html = render("demon-stronghold");
expect(html).toContain("demon-stronghold/maps/runewaker-map.png");
expect(html).toContain("map-player");
});
it("only draws patrol overlays on request", () => {
expect(render("wailing-caverns", { showPatrols: true })).toContain("map-patrol");
+10 -10
View File
@@ -2,7 +2,7 @@ import { useEffect, useId, useState } from "react";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { useGameStore } from "../game/store";
import { resolveContentUrl } from "../content/contentManager";
import { wowDungeonMapFor, wowMapContains, wowMapFloorFor, wowMapHeading, wowMapPoint } from "../game/wowDungeonMap";
import { dungeonArtworkContains, dungeonArtworkFloorFor, dungeonArtworkHeading, dungeonArtworkMapFor, dungeonArtworkPoint } from "../game/dungeonArtworkMap";
export type MapArtworkStatus = "loading" | "ready" | "unavailable";
@@ -17,30 +17,30 @@ export function DungeonMapGraphic({ position, yaw, compact = false, dungeonId, f
}) {
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const dungeon = requireDungeonDefinition(dungeonId ?? activeDungeonId);
const map = wowDungeonMapFor(dungeon);
const floor = map ? wowMapFloorFor(map, position, floorLevel) : null;
const map = dungeonArtworkMapFor(dungeon);
const floor = map ? dungeonArtworkFloorFor(map, position, floorLevel) : null;
const [failedImage, setFailedImage] = useState<string | null>(null);
const [loadedImage, setLoadedImage] = useState<string | null>(null);
const artwork = map && floor && failedImage !== floor.image ? floor : null;
const status = !artwork ? "unavailable" : loadedImage === artwork.image ? "ready" : "loading";
useEffect(() => { onArtworkStatus?.(status); }, [onArtworkStatus, status]);
const presentation = dungeon.presentation.map;
const viewWidth = artwork ? 1002 : presentation.width;
const viewHeight = artwork ? 668 : presentation.height;
const viewWidth = artwork && map ? map.width : presentation.width;
const viewHeight = artwork && map ? map.height : presentation.height;
const { padding, contours } = presentation;
const width = Math.max(1, dungeon.bounds.max[0] - dungeon.bounds.min[0]);
const depth = Math.max(1, dungeon.bounds.max[2] - dungeon.bounds.min[2]);
const project = (point: readonly [number, number, number]): readonly [number, number] => artwork && map
? wowMapPoint(map, artwork, point)
? dungeonArtworkPoint(map, artwork, point)
: [padding + ((point[0] - dungeon.bounds.min[0]) / width) * (viewWidth - padding * 2),
padding + ((point[2] - dungeon.bounds.min[2]) / depth) * (viewHeight - padding * 2)];
const visible = (point: readonly [number, number, number]) => !artwork || !map || wowMapContains(map, artwork, point);
const visible = (point: readonly [number, number, number]) => !artwork || !map || dungeonArtworkContains(map, artwork, point);
const [playerX, playerY] = project(position);
const [headingX, headingY] = artwork && map ? wowMapHeading(map, artwork, position, yaw) : [Math.sin(yaw) * 12, Math.cos(yaw) * 12];
const [headingX, headingY] = artwork && map ? dungeonArtworkHeading(map, artwork, position, yaw) : [Math.sin(yaw) * 12, Math.cos(yaw) * 12];
const markerScale = artwork ? 2 : 1;
const id = `dungeon-map-${useId().replace(/:/g, "")}`;
return (
<svg viewBox={`0 0 ${viewWidth} ${viewHeight}`} role="img" aria-label={`${dungeon.title}${artwork ? ` — ${artwork.name}, original game map` : " survey map"}`} data-artwork={status} data-floor={artwork?.level}>
<svg viewBox={`0 0 ${viewWidth} ${viewHeight}`} role="img" aria-label={`${dungeon.title}${artwork ? ` — ${artwork.name}, ${map?.kind === "runewaker" && map.source.kind === "navigation" ? "navigation floorplan" : "original game map"}` : " survey map"}`} data-artwork={status} data-floor={artwork?.level}>
<defs>
<filter id={`${id}-glow`}><feGaussianBlur stdDeviation="2.5" result="blur" /></filter>
<pattern id={`${id}-grid`} width="28" height="28" patternUnits="userSpaceOnUse">
@@ -49,7 +49,7 @@ export function DungeonMapGraphic({ position, yaw, compact = false, dungeonId, f
<clipPath id={`${id}-clip`}><rect width={viewWidth} height={viewHeight} /></clipPath>
</defs>
<rect width={viewWidth} height={viewHeight} fill={`url(#${id}-grid)`} />
{artwork ? <image key={artwork.image} href={resolveContentUrl(artwork.image)} width="1002" height="668" preserveAspectRatio="none"
{artwork ? <image key={artwork.image} href={resolveContentUrl(artwork.image)} width={viewWidth} height={viewHeight} preserveAspectRatio="none"
onLoad={() => setLoadedImage(artwork.image)} onError={() => setFailedImage(artwork.image)} />
: contours.map((path, index) => <path key={path} className={index === 0 ? "map-contour" : "map-contour map-contour--thin"} d={path} />)}
<g clipPath={`url(#${id}-clip)`}>
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useMemo, useState } from "react";
import { sessionCanUseGmTools } from "../app/releaseFeatures";
import { useShellStore } from "../app/shellStore";
import { useDungeonQueuePolicyStore } from "../app/dungeonQueuePolicyStore";
import { DUNGEON_CATALOG } from "../game/dungeonCatalog";
import { queueModeForDungeon, type DungeonQueueMode } from "../game/dungeonQueuePolicy";
import { BrandMark, FrontSurface } from "./FrontSurface";
const MODES: readonly { id: DungeonQueueMode; label: string }[] = [
{ id: "regular", label: "Random Dungeon" },
{ id: "biteSized", label: "Random Regular Bite-Sized" },
{ id: "randomizedBiteSized", label: "Randomized Bite-Sized" },
];
export function DungeonQueuePolicyScreen({ onClose }: { readonly onClose: () => void }) {
const session = useShellStore((state) => state.session);
const [mode, setMode] = useState<DungeonQueueMode>("regular");
const [query, setQuery] = useState("");
const [notice, setNotice] = useState("");
const draft = useDungeonQueuePolicyStore((state) => state.draft);
const revision = useDungeonQueuePolicyStore((state) => state.revision);
const loading = useDungeonQueuePolicyStore((state) => state.loading);
const saving = useDungeonQueuePolicyStore((state) => state.saving);
const dirty = useDungeonQueuePolicyStore((state) => state.dirty);
const error = useDungeonQueuePolicyStore((state) => state.error);
const refresh = useDungeonQueuePolicyStore((state) => state.refresh);
const setEnabled = useDungeonQueuePolicyStore((state) => state.setEnabled);
const resetDraft = useDungeonQueuePolicyStore((state) => state.resetDraft);
const publish = useDungeonQueuePolicyStore((state) => state.publish);
useEffect(() => { if (sessionCanUseGmTools(session)) void refresh(session); }, [refresh, session]);
const dungeons = useMemo(() => DUNGEON_CATALOG
.filter((dungeon) => queueModeForDungeon(dungeon) === mode)
.filter((dungeon) => dungeon.title.toLowerCase().includes(query.trim().toLowerCase()))
.sort((left, right) => left.title.localeCompare(right.title)), [mode, query]);
const total = DUNGEON_CATALOG.filter((dungeon) => queueModeForDungeon(dungeon) === mode).length;
const enabledCount = total - draft.disabled[mode].length;
if (!sessionCanUseGmTools(session)) return (
<FrontSurface className="dungeon-queue-policy" ariaLabel="GM access denied">
<header className="dungeon-queue-policy__header"><BrandMark compact /><strong>GM access required</strong><button className="front-button" type="button" onClick={onClose}>Back</button></header>
</FrontSurface>
);
return (
<FrontSurface className="dungeon-queue-policy" ariaLabel="Random dungeon queue settings">
<header className="dungeon-queue-policy__header">
<BrandMark compact />
<div><span>Server policy · revision {revision}</span><strong>Random Dungeon Queues</strong></div>
<button className="front-button front-button--quiet" type="button" onClick={onClose}>Back</button>
</header>
<p className="dungeon-queue-policy__intro">Choose which locations can be rolled in each random mode. This affects random queue selection and the next Bite-Sized run. Players can still choose a dungeon directly.</p>
<div className="dungeon-queue-policy__filters">
<label>Queue<select value={mode} onChange={(event) => { setMode(event.target.value as DungeonQueueMode); setQuery(""); }}>
{MODES.map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}
</select></label>
<label>Find dungeon<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search this queue" /></label>
<span>{enabledCount} of {total} enabled</span>
</div>
<main className="dungeon-queue-policy__list">
{loading && <p role="status">Loading queue settings…</p>}
{!loading && dungeons.length === 0 && <p>No dungeons match this search.</p>}
{dungeons.map((dungeon) => <label className="dungeon-queue-policy__row" key={dungeon.id}>
<input type="checkbox" checked={!draft.disabled[mode].includes(dungeon.id)}
onChange={(event) => { setEnabled(mode, dungeon.id, event.target.checked); setNotice(""); }} />
<span><strong>{dungeon.title}</strong><small>{dungeon.location} · {dungeon.available ? "Enterable" : "Not yet enterable"}</small></span>
<em>{draft.disabled[mode].includes(dungeon.id) ? "Disabled" : "Enabled"}</em>
</label>)}
</main>
<footer className="dungeon-queue-policy__actions">
{error && <span role="alert">{error}</span>}{notice && <span role="status">{notice}</span>}
<button className="front-button" type="button" disabled={!dirty || saving} onClick={() => { resetDraft(); setNotice("Changes discarded."); }}>Discard changes</button>
<button className="front-button front-button--primary" type="button" disabled={!dirty || saving} onClick={() => void publish(session).then((saved) => { if (saved) setNotice("Queue settings published."); })}>{saving ? "Publishing…" : "Publish settings"}</button>
</footer>
</FrontSurface>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from "react";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
import { formatDungeonDuration } from "../game/dungeonRunTypes";
import "./dungeonRace.css";
export function DungeonRaceHud() {
const race = useDungeonRaceStore();
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const interval = window.setInterval(() => setNow(Date.now()), 100);
return () => window.clearInterval(interval);
}, []);
if (!race.inGame || !race.opponent) return null;
const serverNow = now + race.serverOffset;
const remaining = race.startAt ? Math.max(0, Math.ceil((race.startAt - serverNow) / 1000)) : null;
const elapsed = race.startAt ? Math.max(0, serverNow - race.startAt) : 0;
const shownTime = race.clearDurationMs ?? elapsed;
const toast = race.result && race.toastAt && now - race.toastAt < 4_000;
return <>
<section className="dungeon-race-hud" aria-label="Dungeon race status">
<span>Dungeon Race · {race.mode === "practice" ? "Practice" : "Ranked"}</span>
<strong>{formatDungeonDuration(shownTime)}</strong>
<small>vs {race.opponent.name}{race.opponent.type === "bot" ? ` · ${race.opponent.tier}` : ""}
{race.opponentProgress !== null ? ` · ${race.opponentProgress}%` : ""}</small>
</section>
{race.status === "preparing" && <div className="dungeon-race-wait" role="status">Waiting for the rival party to be ready…</div>}
{remaining !== null && remaining > 0 && race.status === "countdown" &&
<div className="dungeon-race-countdown" role="status" aria-live="polite">Race starts in <strong>{remaining}</strong></div>}
{toast && <div className={`dungeon-race-toast dungeon-race-toast--${race.result}`} role="status" aria-live="polite">
{race.result === "win" ? "You win" : "You lose"}
{race.result === "loss" ? <small>Keep going to claim the boss card.</small> : null}
</div>}
</>;
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect, useMemo, useState } from "react";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { useDungeonRaceStore, type RacePool } from "../app/dungeonRaceStore";
import { loadDungeonQueuePolicy, useDungeonQueuePolicyStore } from "../app/dungeonQueuePolicyStore";
import { requestOnlineJson } from "../app/onlineAccountClient";
import { type RaceBotSource, type RaceBotTier } from "../app/dungeonRaceBot";
import { dungeonsByCategory, randomizedBiteSizedDungeons } from "../game/dungeonCatalog";
import { dungeonEnabledForQueue } from "../game/dungeonQueuePolicy";
import { formatDungeonDuration } from "../game/dungeonRunTypes";
import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyStore";
import { specializationById } from "../game/specializationCatalog";
import { BrandMark, FrontSurface } from "./FrontSurface";
import "./dungeonRace.css";
interface BoardEntry { readonly accountId: string; readonly username: string; readonly rank: number;
readonly wins?: number; readonly losses?: number; readonly durationMs?: number }
interface BoardResponse { readonly entries: readonly BoardEntry[]; readonly total: number }
export function DungeonRaceScreen({ onClose }: { readonly onClose: () => void }) {
const character = useShellStore((state) => state.activeCharacter) ?? useShellStore(selectedCharacter);
const session = useShellStore((state) => state.session);
const race = useDungeonRaceStore();
const policy = useDungeonQueuePolicyStore((state) => state.config);
const refreshPolicy = useDungeonQueuePolicyStore((state) => state.refresh);
const [pool, setPool] = useState<RacePool>("authored");
const [dungeonId, setDungeonId] = useState("");
const [practice, setPractice] = useState(!session?.accessToken);
const [tier, setTier] = useState<RaceBotTier>("regular");
const [source, setSource] = useState<RaceBotSource>("wow");
const [role, setRole] = useState<PartyRole | null>(null);
const [period, setPeriod] = useState<"30d" | "all">("30d");
const [boardScope, setBoardScope] = useState<"overall" | "dungeon">("overall");
const [boardView, setBoardView] = useState<"wins" | "times">("wins");
const [board, setBoard] = useState<BoardResponse | null>(null);
const [boardError, setBoardError] = useState("");
useEffect(() => { void refreshPolicy(session); }, [refreshPolicy, session]);
const allEntries = useMemo(() => (pool === "randomized" ? randomizedBiteSizedDungeons()
: dungeonsByCategory("bite-sized")).filter((entry) => entry.available && dungeonEnabledForQueue(policy, entry)),
[pool, policy]);
const boardDungeonId = dungeonId || allEntries[0]?.id || "";
useEffect(() => {
const controller = new AbortController();
const query = new URLSearchParams({ view: boardScope === "overall" ? "wins" : boardView,
period, ...(boardScope === "dungeon" && boardDungeonId ? { dungeon: boardDungeonId } : {}) });
if (boardScope === "dungeon" && !boardDungeonId) { setBoard(null); return; }
void requestOnlineJson<BoardResponse>(`/api/dungeon-races/leaderboard?${query}`, { signal: controller.signal })
.then((value) => { setBoard(value); setBoardError(""); })
.catch((error: unknown) => { if (!controller.signal.aborted) { setBoard(null);
setBoardError(error instanceof Error ? error.message : "Leaderboard unavailable."); } });
return () => controller.abort();
}, [boardDungeonId, boardScope, boardView, period]);
const specialization = specializationById(character?.specializationId);
const roles = character?.categoryId === "rom" ? partyRolesForClass(character.classId)
: specialization ? [specialization.role] : [];
const eligibleCharacter = character && ["wow", "rom"].includes(character.categoryId ?? "wow");
const selectedRole = role && roles.includes(role) ? role : roles[0];
const busy = ["queued", "preparing", "countdown", "active"].includes(race.status) || race.preparingContent;
const start = async () => {
if (!character || !eligibleCharacter || !selectedRole || !allEntries.length) return;
const latest = await loadDungeonQueuePolicy();
const candidates = allEntries.filter((entry) => dungeonEnabledForQueue(latest, entry));
if (!candidates.length) { useDungeonRaceStore.setState({ error: "No dungeon is enabled for this race queue." }); return; }
if (practice) await race.startPractice(character, selectedRole,
dungeonId || candidates[Math.floor(Math.random() * candidates.length)].id, tier, source);
else if (session?.accessToken) await race.startOnline(session, character, selectedRole, pool, dungeonId || undefined);
};
return <FrontSurface className="dungeon-race-screen" ariaLabel="Bite-Sized Dungeon Race">
<header className="dungeon-race-screen__header"><BrandMark compact /><div><small>Main menu / Dungeon Race</small>
<h1>Bite-Sized Dungeon Race</h1></div><button className="front-button" type="button" onClick={() => { void race.leave(); onClose(); }}>Main menu</button></header>
<main className="dungeon-race-screen__layout">
<section className="dungeon-race-screen__setup">
<p>Race another player or a full NPC party through matching short dungeons. First final boss kill wins. You can still finish and earn its card after a loss.</p>
<div className="dungeon-race-screen__choices" role="group" aria-label="Race type">
<button className="front-button" type="button" aria-pressed={!practice} disabled={!session?.accessToken || busy} onClick={() => setPractice(false)}>Online ranked</button>
<button className="front-button" type="button" aria-pressed={practice} disabled={busy} onClick={() => setPractice(true)}>Offline practice</button>
</div>
<div className="dungeon-race-screen__choices" role="group" aria-label="Dungeon pool">
<button className="front-button" type="button" aria-pressed={pool === "authored"} disabled={busy}
onClick={() => { setPool("authored"); setDungeonId(""); }}>Bite-Sized</button>
<button className="front-button" type="button" aria-pressed={pool === "randomized"} disabled={busy}
onClick={() => { setPool("randomized"); setDungeonId(""); }}>Randomized Bite-Sized</button>
</div>
<label>Dungeon<select value={dungeonId} disabled={busy} onChange={(event) => setDungeonId(event.target.value)}>
<option value="">Random eligible dungeon</option>
{allEntries.map((entry) => <option key={entry.id} value={entry.id}>{entry.title}</option>)}
</select></label>
<div className="dungeon-race-screen__choices" role="group" aria-label="Your role">
{roles.map((candidate) => <button key={candidate} className="front-button" type="button"
aria-pressed={selectedRole === candidate} disabled={busy} onClick={() => setRole(candidate)}>{PARTY_ROLE_LABELS[candidate]}</button>)}
</div>
{practice && <div className="dungeon-race-screen__practice">
<label>NPC skill<select value={tier} onChange={(event) => setTier(event.target.value as RaceBotTier)}>
<option value="novice">Novice</option><option value="regular">Regular</option>
<option value="veteran">Veteran</option><option value="elite">Elite</option>
</select></label>
<label>NPC party<select value={source} onChange={(event) => setSource(event.target.value as RaceBotSource)}>
<option value="wow">WoW</option><option value="rom">RoM</option>
</select></label>
<small>Practice awards local boss cards and never enters a leaderboard.</small>
</div>}
{!eligibleCharacter && <p role="status">Select a WoW or RoM character to race. CoA characters will be added later.</p>}
{!session?.accessToken && !practice && <p role="status">Sign in online for ranked races.</p>}
{!allEntries.length && <p role="status">No Bite-Sized dungeon is enabled for this queue.</p>}
{race.status === "queued" && <p role="status">Searching for a player. An NPC team joins after five seconds.</p>}
{race.status === "preparing" && <p role="status">{race.preparingContent ? "Installing race content…" : "Preparing both parties…"}</p>}
{race.error && <p role="alert">{race.error}</p>}
<div className="dungeon-race-screen__choices"><button className="front-button front-button--primary" type="button"
disabled={busy || !eligibleCharacter || !selectedRole || !allEntries.length} onClick={() => void start()}>
{practice ? "Start practice" : "Enter race queue"}</button>
{busy && <button className="front-button" type="button" onClick={() => void race.leave()}>Cancel race</button>}
</div>
</section>
<section className="dungeon-race-screen__board" aria-label="Dungeon Race leaderboards">
<h2>Leaderboards</h2>
<div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={boardScope === "overall"} onClick={() => setBoardScope("overall")}>Overall wins</button>
<button className="front-button" type="button" aria-pressed={boardScope === "dungeon"} onClick={() => setBoardScope("dungeon")}>This dungeon</button></div>
{boardScope === "dungeon" && <div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={boardView === "wins"} onClick={() => setBoardView("wins")}>Wins &amp; losses</button>
<button className="front-button" type="button" aria-pressed={boardView === "times"} onClick={() => setBoardView("times")}>Fastest clears</button></div>}
<div className="dungeon-race-screen__choices"><button className="front-button" type="button" aria-pressed={period === "30d"} onClick={() => setPeriod("30d")}>Last 30 days</button>
<button className="front-button" type="button" aria-pressed={period === "all"} onClick={() => setPeriod("all")}>All time</button></div>
{boardError && <p role="status">{boardError}</p>}
{!boardError && board?.entries.length === 0 && <p>No results yet.</p>}
{board?.entries.length ? <table><thead><tr><th>Rank</th><th>Player</th>
<th>{boardScope === "dungeon" && boardView === "times" ? "Best time" : "Wins"}</th>
{!(boardScope === "dungeon" && boardView === "times") && <th>Losses</th>}</tr></thead><tbody>
{board.entries.map((entry) => <tr key={entry.accountId}><td>#{entry.rank}</td><td>{entry.username}</td>
<td>{entry.durationMs !== undefined ? formatDungeonDuration(entry.durationMs) : entry.wins}</td>
{entry.durationMs === undefined && <td>{entry.losses}</td>}</tr>)}</tbody></table> : null}
</section>
</main>
</FrontSurface>;
}
+30 -8
View File
@@ -15,11 +15,14 @@ import { ControllerButton } from "./ControllerButton";
import { DungeonRunDetails } from "./DungeonRunDetails";
import "./dungeonRuns.css";
import { isLeaderboardFullClear } from "../game/dungeonLeaderboardRules";
import { useDungeonRaceStore } from "../app/dungeonRaceStore";
export interface RunPlacementState {
readonly scope: LeaderboardScope;
readonly scope: LeaderboardScope | "race";
readonly placement: DungeonRunPlacement | null;
readonly loading: boolean;
readonly raceResult?: "win" | "loss" | null;
readonly raceTimeMs?: number | null;
}
export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, onRetry, onAnother,
@@ -66,14 +69,17 @@ export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, o
<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 aria-live="polite"><small>{ranking.scope === "race" ? "Dungeon Race" : ranking.scope === "world" ? "World leaderboard" : "This device leaderboard"}</small>
<strong>{ranking.scope === "race" ? ranking.loading ? "Recording…" : ranking.raceResult === "win" ? "You win" : ranking.raceResult === "loss" ? "You lose" : "No result"
: ranking.loading ? "Recording…" : ranking.placement ? `#${ranking.placement.finalRank}` : "Unranked"}</strong>
{ranking.scope === "race" && ranking.raceTimeMs != null ? <span>Race clear: {formatDungeonDuration(ranking.raceTimeMs)}</span> : null}
{ranking.scope !== "race" && 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}
{ranking.scope === "race" && ranking.raceResult === "loss" ? <p className="dungeon-results__note">Your boss card and loot are still yours.</p> : null}
{anotherMessage ? <p className="dungeon-results__note" role="status">{anotherMessage}</p> : null}
<div className="dungeon-results__details"><DungeonRunDetails dungeonId={progress.dungeonId}
difficultyId={progress.difficultyId} bossDifficulty={progress.bossDifficulty} party={progress.party} killedBossIds={progress.killedBossIds} /></div>
@@ -104,8 +110,16 @@ export function DungeonRunResults() {
const sessionOwnerId = useShellStore((state) => state.session?.ownerId ?? null);
const online = useOnlineSessionStore((state) => state.snapshot);
const [ranking, setRanking] = useState<RunPlacementState>({ scope: "world", placement: null, loading: false });
const race = useDungeonRaceStore();
const raceRun = Boolean(record && (race.seed === record.id
|| record.id.startsWith("race-") || record.id.startsWith("practice-")));
useEffect(() => {
if (!record) { setRanking({ scope: "world", placement: null, loading: false }); return; }
if (raceRun) {
setRanking({ scope: "race", placement: null, loading: race.mode === "online" && race.result === null,
raceResult: race.result, raceTimeMs: race.clearDurationMs });
return;
}
let live = true;
saveLocalDungeonRun(record);
setRanking({ scope: "world", placement: null, loading: true });
@@ -116,11 +130,11 @@ export function DungeonRunResults() {
.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]);
}, [record, retry, raceRun, race.mode, race.result, race.clearDurationMs]);
if (overlay !== "dungeon-results" || !progress || progress.finishedAt === null) return null;
const biteSized = dungeonById(progress.dungeonId)?.category === "bite-sized";
const partyLeader = group ? group.leaderAccountId === sessionOwnerId : !online || online.authority;
const canStartAnother = biteSized && partyLeader;
const canStartAnother = biteSized && partyLeader && !raceRun;
const leaderMessage = biteSized && !partyLeader
? "The party leader can start the next random Bite-Sized dungeon."
: "";
@@ -134,9 +148,17 @@ export function DungeonRunResults() {
} : undefined}
startingAnother={startingAnother || groupBusy}
anotherMessage={anotherError || leaderMessage}
onLoot={() => useGameStore.getState().closeOverlay()} onFinish={() => {
onLoot={() => useGameStore.getState().closeOverlay()} onFinish={() => { void (async () => {
if (raceRun) await useDungeonRaceStore.getState().reportClear(progress.killedBossIds);
const currentRace = useDungeonRaceStore.getState();
if (raceRun && currentRace.mode === "online" && currentRace.status !== "void"
&& currentRace.clearDurationMs === null) {
setAnotherError(currentRace.error || "The race clear has not synced yet. Try again in a moment.");
return;
}
useShellStore.getState().saveActiveDungeonRunCheckpoint(null);
useGameStore.getState().closeOverlay();
useShellStore.getState().returnToMainMenu();
}} />;
if (raceRun) useDungeonRaceStore.getState().reset();
})(); }} />;
}
+4
View File
@@ -26,6 +26,7 @@ import { BrandMark, FrontSurface } from "./FrontSurface";
import { CompanionPoolControls } from "./CompanionPoolControls";
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
import { RandomizedCreatureReadinessScreen } from "./RandomizedCreatureReadinessScreen";
import { DungeonQueuePolicyScreen } from "./DungeonQueuePolicyScreen";
const COOLDOWN_MECHANICS = BOSS_TESTING_CATALOG.flatMap((boss) => {
const entity = requireDungeonDefinition(boss.dungeonId).entities[boss.entityId];
@@ -64,6 +65,7 @@ export function DungeonSimulationAdminScreen() {
const [message, setMessage] = useState("");
const [readinessOpen, setReadinessOpen] = useState(false);
const [creatureReadinessOpen, setCreatureReadinessOpen] = useState(false);
const [queuePolicyOpen, setQueuePolicyOpen] = useState(false);
const [selectedCooldownMechanic, setSelectedCooldownMechanic] = useState(COOLDOWN_MECHANICS[0]?.key ?? "");
const cooldownDraft = useCompanionCooldownPolicyStore((state) => state.draft);
const cooldownRevision = useCompanionCooldownPolicyStore((state) => state.revision);
@@ -215,6 +217,7 @@ export function DungeonSimulationAdminScreen() {
if (readinessOpen) return <CompanionReadinessScreen onClose={() => setReadinessOpen(false)} />;
if (creatureReadinessOpen) return <RandomizedCreatureReadinessScreen onClose={() => setCreatureReadinessOpen(false)} />;
if (queuePolicyOpen) return <DungeonQueuePolicyScreen onClose={() => setQueuePolicyOpen(false)} />;
return (
<FrontSurface className="dungeon-sim-admin" ariaLabel="Dungeon simulation route review">
@@ -222,6 +225,7 @@ export function DungeonSimulationAdminScreen() {
<BrandMark />
<div><p className="eyebrow">Game Master tools</p><h1>Dungeon route simulations</h1></div>
<div className="dungeon-sim-admin__header-actions">
<button className="front-button" type="button" onClick={() => setQueuePolicyOpen(true)}>Random queues</button>
<button className="front-button" type="button" onClick={() => setCreatureReadinessOpen(true)}>Creature readiness</button>
<button className="front-button" type="button" onClick={() => void refresh()} disabled={busy}>Refresh</button>
<button className="front-button" type="button" onClick={returnToMainMenu}>Main menu</button>
+39 -7
View File
@@ -21,8 +21,11 @@ const BossCardCollectionScreen = lazy(() => import("./BossCardCollection"));
const BossTestingScreen = lazy(() => import("./BossTestingScreen").then((module) => ({ default: module.BossTestingScreen })));
const BalanceLabScreen = lazy(() => import("./BalanceLabScreen").then((module) => ({ default: module.BalanceLabScreen })));
const BalanceTuningScreen = lazy(() => import("./BalanceTuningScreen").then((module) => ({ default: module.BalanceTuningScreen })));
const PartyBuffPolicyScreen = lazy(() => import("./PartyBuffPolicyScreen").then((module) => ({ default: module.PartyBuffPolicyScreen })));
const DungeonQueuePolicyScreen = lazy(() => import("./DungeonQueuePolicyScreen").then((module) => ({ default: module.DungeonQueuePolicyScreen })));
const CompanionReadinessScreen = lazy(() => import("./CompanionReadinessScreen").then((module) => ({ default: module.CompanionReadinessScreen })));
const RandomizedBiteSizedScreen = lazy(() => import("./RandomizedBiteSizedScreen").then((module) => ({ default: module.RandomizedBiteSizedScreen })));
const DungeonRaceScreen = lazy(() => import("./DungeonRaceScreen").then((module) => ({ default: module.DungeonRaceScreen })));
const GAME_MODES = [
{
@@ -53,6 +56,13 @@ const GAME_MODES = [
description: "Run a short dungeon with every mob and the final boss rerolled.",
available: true,
},
{
id: "dungeon-race",
number: "05",
name: "Dungeon Race",
description: "Race a player or NPC party through matching Bite-Sized dungeons.",
available: true,
},
...(SHELVED_MODES_ENABLED ? [{
id: "manastorms" as const,
number: "02",
@@ -103,30 +113,39 @@ export function MainMenuScreen() {
const [bossTestingOpen, setBossTestingOpen] = useState(false);
const [balanceLabOpen, setBalanceLabOpen] = useState(false);
const [balanceTuningOpen, setBalanceTuningOpen] = useState(false);
const [partyBuffPolicyOpen, setPartyBuffPolicyOpen] = useState(false);
const [dungeonQueuePolicyOpen, setDungeonQueuePolicyOpen] = useState(false);
const [readinessOpen, setReadinessOpen] = useState(false);
const [randomizedBiteSizedOpen, setRandomizedBiteSizedOpen] = useState(false);
const [dungeonRaceOpen, setDungeonRaceOpen] = useState(false);
const [editor, setEditor] = useState<MainMenuEditor | null>(null);
if (editor) return <MainMenuCharacterEditor editor={editor} onClose={() => setEditor(null)} />;
if (bossTestingOpen) return <Suspense fallback={<p role="status">Opening Boss Testing…</p>}><BossTestingScreen onClose={() => setBossTestingOpen(false)} /></Suspense>;
if (balanceLabOpen) return <Suspense fallback={<p role="status">Opening Balance Lab…</p>}><BalanceLabScreen onClose={() => setBalanceLabOpen(false)} /></Suspense>;
if (balanceTuningOpen) return <Suspense fallback={<p role="status">Opening balance tuning…</p>}><BalanceTuningScreen onClose={() => setBalanceTuningOpen(false)} /></Suspense>;
if (partyBuffPolicyOpen) return <Suspense fallback={<p role="status">Opening party buffs…</p>}><PartyBuffPolicyScreen onClose={() => setPartyBuffPolicyOpen(false)} /></Suspense>;
if (dungeonQueuePolicyOpen) return <Suspense fallback={<p role="status">Opening queue settings…</p>}><DungeonQueuePolicyScreen onClose={() => setDungeonQueuePolicyOpen(false)} /></Suspense>;
if (readinessOpen) return <Suspense fallback={<p role="status">Opening Companion Readiness…</p>}><CompanionReadinessScreen onClose={() => setReadinessOpen(false)} /></Suspense>;
if (randomizedBiteSizedOpen) return <Suspense fallback={<p role="status">Opening Randomized Bite-Sized…</p>}><RandomizedBiteSizedScreen onClose={() => setRandomizedBiteSizedOpen(false)} /></Suspense>;
if (dungeonRaceOpen) return <Suspense fallback={<p role="status">Opening Dungeon Race…</p>}><DungeonRaceScreen onClose={() => setDungeonRaceOpen(false)} /></Suspense>;
if (cardsOpen) return <Suspense fallback={<p role="status">Opening collection log…</p>}><BossCardCollectionScreen onClose={() => setCardsOpen(false)} /></Suspense>;
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenBalanceTuning={() => setBalanceTuningOpen(true)} onOpenReadiness={() => setReadinessOpen(true)} onOpenRandomizedBiteSized={() => setRandomizedBiteSizedOpen(true)} onOpenEditor={setEditor} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenBalanceTuning={() => setBalanceTuningOpen(true)} onOpenPartyBuffPolicy={() => setPartyBuffPolicyOpen(true)} onOpenDungeonQueuePolicy={() => setDungeonQueuePolicyOpen(true)} onOpenReadiness={() => setReadinessOpen(true)} onOpenRandomizedBiteSized={() => setRandomizedBiteSizedOpen(true)} onOpenDungeonRace={() => setDungeonRaceOpen(true)} onOpenEditor={setEditor} />;
}
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenBalanceTuning, onOpenReadiness, onOpenRandomizedBiteSized, onOpenEditor }: {
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenBalanceTuning, onOpenPartyBuffPolicy, onOpenDungeonQueuePolicy, onOpenReadiness, onOpenRandomizedBiteSized, onOpenDungeonRace, onOpenEditor }: {
readonly onOpenDownloads: () => void;
readonly onOpenReports: () => void;
readonly onOpenCards: () => void;
readonly onOpenBossTesting: () => void;
readonly onOpenBalanceLab: () => void;
readonly onOpenBalanceTuning: () => void;
readonly onOpenPartyBuffPolicy: () => void;
readonly onOpenDungeonQueuePolicy: () => void;
readonly onOpenReadiness: () => void;
readonly onOpenRandomizedBiteSized: () => void;
readonly onOpenDungeonRace: () => void;
readonly onOpenEditor: (editor: MainMenuEditor) => void;
}) {
const [gmMenuOpen, setGmMenuOpen] = useState(false);
@@ -206,6 +225,8 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
{ id: "gm-menu-boss-testing", run: onOpenBossTesting, ...(!isGm ? { neighbors: { up: "gm-menu-close" } } : {}) },
{ id: "gm-menu-companion-readiness", run: onOpenReadiness },
...(isGm ? [{ id: "gm-menu-balance-tuning", run: onOpenBalanceTuning }] : []),
...(isGm ? [{ id: "gm-menu-party-buffs", run: onOpenPartyBuffPolicy }] : []),
...(isGm ? [{ id: "gm-menu-dungeon-queues", run: onOpenDungeonQueuePolicy }] : []),
...(isGm ? [{ id: "gm-menu-balance-lab", run: onOpenBalanceLab }] : []),
{ id: "gm-menu-reports", run: onOpenReports },
{ id: "gm-menu-close", run: () => setGmMenuOpen(false), neighbors: { down: isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing" } },
@@ -223,13 +244,15 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
{
id: "mode-random-bite-sized",
run: openRandomBiteSizedDungeon,
neighbors: { up: "mode-dungeons", right: "mode-randomized-bite-sized" },
neighbors: { up: "mode-dungeons", right: "mode-randomized-bite-sized", down: "mode-dungeon-race" },
},
{
id: "mode-randomized-bite-sized",
run: onOpenRandomizedBiteSized,
neighbors: { up: "mode-random-dungeon", left: "mode-random-bite-sized" },
neighbors: { up: "mode-random-dungeon", left: "mode-random-bite-sized", down: "mode-dungeon-race" },
},
{ id: "mode-dungeon-race", run: onOpenDungeonRace,
neighbors: { up: "mode-random-bite-sized" } },
...(character ? [
{ id: "mode-class-hall", run: openClassHall },
{ id: "mode-talents", run: () => onOpenEditor("talents") },
@@ -244,7 +267,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
] : []),
{ id: "mode-downloads", run: onOpenDownloads },
{ id: "back-to-roster", run: returnToCharacters, neighbors: { down: "mode-dungeons" } },
], [availableRoles, character, closeRandomQueue, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBalanceTuning, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, openRandomBiteSizedDungeon, openRandomDungeon, queueing, randomQueueMode, returnToCharacters, selectedRole, startRandomQueue]);
], [availableRoles, character, closeRandomQueue, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBalanceTuning, onOpenPartyBuffPolicy, onOpenDungeonQueuePolicy, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenDungeonRace, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, openRandomBiteSizedDungeon, openRandomDungeon, queueing, randomQueueMode, returnToCharacters, selectedRole, startRandomQueue]);
const controller = useMenuController(actions, {
columns: 2,
initialId: randomQueueMode ? `random-queue-role-${availableRoles[0]}` : gmMenuOpen ? (isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing") : "mode-dungeons",
@@ -261,6 +284,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
if (id === "random-dungeon") return openRandomDungeon;
if (id === "random-bite-sized") return openRandomBiteSizedDungeon;
if (id === "randomized-bite-sized") return onOpenRandomizedBiteSized;
if (id === "dungeon-race") return onOpenDungeonRace;
return () => openDungeons();
};
const renderGameMode = (mode: (typeof GAME_MODES)[number]) => mode.available ? (
@@ -334,7 +358,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
<section className="main-menu-card main-menu-card--adventures" aria-labelledby="main-menu-adventures-title">
<header><span>01</span><strong id="main-menu-adventures-title">Adventures</strong></header>
<div className="main-menu-card__grid">
{GAME_MODES.filter((mode) => ["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized"].includes(mode.id)).map(renderGameMode)}
{GAME_MODES.filter((mode) => ["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized", "dungeon-race"].includes(mode.id)).map(renderGameMode)}
</div>
</section>
@@ -367,7 +391,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm-menu" type="button" onClick={() => setGmMenuOpen(true)}>
<b aria-hidden="true">GM</b><span><strong>GM Menu</strong><small>Testing, readiness, reports, and balance tools.</small></span><em>Open</em>
</ControllerButton>
{GAME_MODES.filter((mode) => !["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized"].includes(mode.id)).map(renderGameMode)}
{GAME_MODES.filter((mode) => !["dungeons", "random-dungeon", "random-bite-sized", "randomized-bite-sized", "dungeon-race"].includes(mode.id)).map(renderGameMode)}
</div>
</section>
</nav>
@@ -433,6 +457,14 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
className="front-button main-menu-mode main-menu-mode--gm" type="button" disabled={!isGm} onClick={onOpenBalanceTuning}>
<b aria-hidden="true">AT</b><span><strong>Ability &amp; Talent Tuning</strong><small>Adjust local cooldowns, costs, damage, healing, durations, and talent modifiers.</small></span><em>{isGm ? "Local" : "GM account required"}</em>
</ControllerButton>
<ControllerButton controlId="gm-menu-party-buffs" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--gm" type="button" disabled={!isGm} onClick={onOpenPartyBuffPolicy}>
<b aria-hidden="true">PB</b><span><strong>Permanent Party Buffs</strong><small>Choose class buffs and their party, tank, or healer targets.</small></span><em>{isGm ? "Server" : "GM account required"}</em>
</ControllerButton>
<ControllerButton controlId="gm-menu-dungeon-queues" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--gm" type="button" disabled={!isGm} onClick={onOpenDungeonQueuePolicy}>
<b aria-hidden="true">DQ</b><span><strong>Random Dungeon Queues</strong><small>Enable or disable individual dungeons in all three random modes.</small></span><em>{isGm ? "Server" : "GM account required"}</em>
</ControllerButton>
<ControllerButton controlId="gm-menu-balance-lab" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--gm" type="button" disabled={!isGm} onClick={onOpenBalanceLab}>
<b aria-hidden="true">BL</b><span><strong>Balance Lab</strong><small>Queue automatic party and boss simulations on the server.</small></span><em>{isGm ? "Server" : "GM account required"}</em>
+10 -9
View File
@@ -8,7 +8,7 @@ import { DungeonMapGraphic, type MapArtworkStatus } from "./DungeonMapGraphic";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { resolveStagePresentation } from "../game/manastormStagePresentation";
import { useManastormStore } from "../game/manastormStore";
import { wowDungeonMapFor, wowMapContains, wowMapFloorFor, wowMapPoint } from "../game/wowDungeonMap";
import { dungeonArtworkContains, dungeonArtworkFloorFor, dungeonArtworkMapFor, dungeonArtworkPoint } from "../game/dungeonArtworkMap";
export function closeMapForKey(event: Pick<KeyboardEvent, "code" | "ctrlKey" | "altKey" | "shiftKey" | "metaKey" | "repeat" | "defaultPrevented" | "preventDefault" | "stopPropagation">, close: () => void) {
if (event.repeat || event.defaultPrevented || isCapturingInput()) return;
@@ -31,13 +31,14 @@ function MapDialog() {
const encounter = useManastormStore((state) => state.currentEncounter);
const activeDungeonId = useGameStore((state) => state.activeDungeonId);
const dungeon = requireDungeonDefinition(activeDungeonId);
const map = wowDungeonMapFor(dungeon);
const map = dungeonArtworkMapFor(dungeon);
const navigationPlan = map?.kind === "runewaker" && map.source.kind === "navigation";
const [floorLevel, setFloorLevel] = useState<number | null>(null);
const [showPatrols, setShowPatrols] = useState(false);
const [artworkStatus, setArtworkStatus] = useState<MapArtworkStatus>(map ? "loading" : "unavailable");
const floor = map ? wowMapFloorFor(map, position, floorLevel) : null;
const mapPosition = map && floor ? wowMapPoint(map, floor, position) : null;
const positionOnMap = Boolean(map && floor && wowMapContains(map, floor, position));
const floor = map ? dungeonArtworkFloorFor(map, position, floorLevel) : null;
const mapPosition = map && floor ? dungeonArtworkPoint(map, floor, position) : null;
const positionOnMap = Boolean(map && floor && dungeonArtworkContains(map, floor, position));
const stagePresentation = resolveStagePresentation(gameMode, encounter, {
title: dungeon.title,
mapId: dungeon.mapId,
@@ -49,7 +50,7 @@ function MapDialog() {
<aside className="map-panel" role="dialog" aria-modal="true" aria-labelledby="map-title" onKeyDown={(event) => closeMapForKey(event, close)}>
<header>
<div>
<p className="eyebrow">{artworkStatus === "ready" ? "Dungeon atlas" : "Expedition survey"}</p>
<p className="eyebrow">{artworkStatus === "ready" ? navigationPlan ? "Dungeon floorplan" : "Dungeon atlas" : "Expedition survey"}</p>
<h2 id="map-title">{stagePresentation.title}</h2>
</div>
<button type="button" className="icon-button" onClick={close} aria-label="Close map">X</button>
@@ -71,15 +72,15 @@ function MapDialog() {
<div className="map-panel__readout">
<div><span>Current area</span><strong>{stagePresentation.areaName}</strong></div>
<div><span>{artworkStatus === "ready" ? floor?.name : "Position"}</span><strong>{artworkStatus === "ready" && mapPosition
? positionOnMap ? `${(mapPosition[0] / 1002 * 100).toFixed(1)}%, ${(mapPosition[1] / 668 * 100).toFixed(1)}%` : "Outside selected floor"
? positionOnMap ? `${(mapPosition[0] / map!.width * 100).toFixed(1)}%, ${(mapPosition[1] / map!.height * 100).toFixed(1)}%` : "Outside selected floor"
: position.map((value) => Math.round(value)).join(" / ")}</strong></div>
</div>
<p className="map-panel__note">
{stagePresentation.usingFallbackEnvironment
? "Stage location is authoritative; the survey artwork is temporary fallback geometry. "
: artworkStatus === "ready"
? "Original game artwork. White: you and camera direction. Green: entrance. Gold: numbered encounters. "
: artworkStatus === "loading" ? "Loading original game artwork. " : "Original artwork unavailable; showing the survey fallback. "}
? `${navigationPlan ? "Navigation floorplan" : "Original game artwork"}. White: you and camera direction. Green: entrance. Gold: numbered encounters. `
: artworkStatus === "loading" ? navigationPlan ? "Loading dungeon floorplan. " : "Loading original game artwork. " : "Original artwork unavailable; showing the survey fallback. "}
{map && map.floors.length > 1 && "Choose a floor when rooms overlap. "}
Press {bindingLabel("pc", "map")} to close or {bindingLabel("controller", "menu-back")} to return to Pause.
</p>
+3 -3
View File
@@ -4,7 +4,7 @@ import { DungeonMapGraphic } from "./DungeonMapGraphic";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { useGameStore } from "../game/store";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { wowDungeonMapFor, wowMapFloorFor, wowMapHeading } from "../game/wowDungeonMap";
import { dungeonArtworkFloorFor, dungeonArtworkHeading, dungeonArtworkMapFor } from "../game/dungeonArtworkMap";
export interface MinimapProps {
position: Vector3Tuple;
@@ -26,8 +26,8 @@ export function Minimap({
const bindingLabel = useInputBindingLabels();
const inputMode = useGameStore((state) => state.inputMode);
const dungeonId = useGameStore((state) => state.activeDungeonId);
const map = wowDungeonMapFor(requireDungeonDefinition(dungeonId));
const direction = map ? wowMapHeading(map, wowMapFloorFor(map, position), position, yaw) : null;
const map = dungeonArtworkMapFor(requireDungeonDefinition(dungeonId));
const direction = map ? dungeonArtworkHeading(map, dungeonArtworkFloorFor(map, position), position, yaw) : null;
const compassYaw = direction ? Math.atan2(direction[0], -direction[1]) : yaw;
const mapKey = bindingLabel(inputMode === "gamepad" ? "controller" : "pc", "map");
const heading = ((Math.round((compassYaw * 180) / Math.PI) % 360) + 360) % 360;
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useMemo, useState } from "react";
import { CLASSES, type ClassId } from "../app/characterCatalog";
import { sessionCanUseGmTools } from "../app/releaseFeatures";
import { useShellStore } from "../app/shellStore";
import { preloadAbilityCatalogsForClasses, sourceAbilitiesForClass } from "../game/abilityCatalog";
import { permanentBuffEffects, type PartyBuffScope } from "../game/partyBuffPolicy";
import { usePartyBuffPolicyStore } from "../game/partyBuffPolicyStore";
import { BrandMark, FrontSurface } from "./FrontSurface";
export function PartyBuffPolicyScreen({ onClose }: { readonly onClose: () => void }) {
const session = useShellStore((state) => state.session);
const [classId, setClassId] = useState<ClassId>("priest");
const [query, setQuery] = useState("");
const [loadedClassId, setLoadedClassId] = useState<ClassId | null>(null);
const [catalogError, setCatalogError] = useState("");
const [notice, setNotice] = useState("");
const draft = usePartyBuffPolicyStore((state) => state.draft);
const revision = usePartyBuffPolicyStore((state) => state.revision);
const dirty = usePartyBuffPolicyStore((state) => state.dirty);
const saving = usePartyBuffPolicyStore((state) => state.saving);
const error = usePartyBuffPolicyStore((state) => state.error);
const replaceDraft = usePartyBuffPolicyStore((state) => state.replaceDraft);
const resetDraft = usePartyBuffPolicyStore((state) => state.resetDraft);
const publish = usePartyBuffPolicyStore((state) => state.publish);
useEffect(() => {
let active = true;
setLoadedClassId(null);
setCatalogError("");
void preloadAbilityCatalogsForClasses([classId]).then(() => {
if (active) setLoadedClassId(classId);
}).catch((failure) => {
if (active) setCatalogError(failure instanceof Error ? failure.message : "Could not load this class.");
});
return () => { active = false; };
}, [classId]);
const abilities = useMemo(() => loadedClassId !== classId ? [] : sourceAbilitiesForClass(classId)
.filter((ability) => permanentBuffEffects(ability).length > 0)
.filter((ability) => ability.name.toLowerCase().includes(query.trim().toLowerCase()))
.sort((left, right) => left.name.localeCompare(right.name)), [classId, loadedClassId, query]);
if (!sessionCanUseGmTools(session)) return (
<FrontSurface className="party-buff-policy" ariaLabel="GM access denied">
<header className="party-buff-policy__header"><BrandMark compact /><strong>GM access required</strong><button className="front-button" type="button" onClick={onClose}>Back</button></header>
</FrontSurface>
);
const setScope = (abilityId: string, scope: PartyBuffScope | "off") => {
replaceDraft({ schemaVersion: 1, rules: [
...draft.rules.filter((rule) => rule.abilityId !== abilityId),
...(scope === "off" ? [] : [{ abilityId, scope }]),
] });
setNotice("");
};
return (
<FrontSurface className="party-buff-policy" ariaLabel="Permanent party buffs">
<header className="party-buff-policy__header">
<BrandMark compact />
<div><span>Server policy · revision {revision}</span><strong>Permanent Party Buffs</strong></div>
<button className="front-button front-button--quiet" type="button" onClick={onClose}>Back</button>
</header>
<p className="party-buff-policy__intro">Choose which active stat buffs are maintained when a party forms or resets. A selected buff needs a member of its class who has learned it. Party includes the player; Tank and Healer target only those roles. If related buffs share a family, the strongest learned selection applies. Unselected spells use their normal cast and duration rules.</p>
<div className="party-buff-policy__filters">
<label>Class<select value={classId} onChange={(event) => setClassId(event.currentTarget.value as ClassId)}>{CLASSES.map((definition) => <option key={definition.id} value={definition.id}>{definition.name}</option>)}</select></label>
<label>Find buff<input type="search" value={query} onChange={(event) => setQuery(event.currentTarget.value)} placeholder="Search this class" /></label>
<span>{draft.rules.length} selected</span>
</div>
<main className="party-buff-policy__list">
{catalogError && <p role="alert">{catalogError}</p>}
{!catalogError && loadedClassId !== classId && <p role="status">Loading class buffs…</p>}
{loadedClassId === classId && !abilities.length && <p>No eligible buffs match this class and search.</p>}
{abilities.map((ability) => {
const scope = draft.rules.find((rule) => rule.abilityId === ability.id)?.scope ?? "off";
return <article key={ability.id} className="party-buff-policy__row">
<img src={ability.icon} alt="" loading="lazy" />
<div><strong>{ability.name}</strong><small>Level {ability.unlockLevel} · {ability.description}</small></div>
<label>Permanent target<select aria-label={`${ability.name} permanent target`} value={scope} onChange={(event) => setScope(ability.id, event.currentTarget.value as PartyBuffScope | "off")}>
<option value="off">Off</option><option value="party">Whole party</option><option value="tank">Tank only</option><option value="healer">Healer only</option>
</select></label>
</article>;
})}
</main>
<footer className="party-buff-policy__actions">
{error && <span role="alert">{error}</span>}{notice && <span role="status">{notice}</span>}
<button className="front-button" type="button" disabled={!draft.rules.length || saving} onClick={() => { replaceDraft({ schemaVersion: 1, rules: [] }); setNotice(""); }}>Clear selections</button>
<button className="front-button" type="button" disabled={!dirty || saving} onClick={() => { resetDraft(); setNotice("Draft reverted."); }}>Discard changes</button>
<button className="front-button front-button--primary" type="button" disabled={!dirty || saving} onClick={() => void publish(session).then((saved) => { if (saved) setNotice("Party buff policy published."); })}>{saving ? "Publishing…" : "Publish policy"}</button>
</footer>
</FrontSurface>
);
}
+23 -9
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { installDungeonShellRuntime } from "../app/dungeonShellRuntime";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
@@ -10,7 +10,9 @@ import {
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
dungeonContentPackId,
} from "../game/dungeonContentSource";
import { dungeonById } from "../game/dungeonCatalog";
import { dungeonById, randomRandomizedBiteSizedDungeon } from "../game/dungeonCatalog";
import { dungeonEnabledForQueue } from "../game/dungeonQueuePolicy";
import { loadDungeonQueuePolicy, useDungeonQueuePolicyStore } from "../app/dungeonQueuePolicyStore";
import { RANDOMIZED_BITE_SIZED_CREATURE_CATALOG, requireDungeonDefinition } from "../game/dungeonRegistry";
import { BrandMark, FrontSurface } from "./FrontSurface";
import { DungeonDifficultySlider } from "./DungeonDifficultySlider";
@@ -38,6 +40,9 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
const [downloading, setDownloading] = useState(false);
const [error, setError] = useState("");
const [readinessOpen, setReadinessOpen] = useState(false);
const queuePolicy = useDungeonQueuePolicyStore((state) => state.config);
const refreshQueuePolicy = useDungeonQueuePolicyStore((state) => state.refresh);
useEffect(() => { void refreshQueuePolicy(session); }, [refreshQueuePolicy, session]);
const character = activeCharacter ?? rosterCharacter;
const specialization = specializationById(character?.specializationId);
const roles = character
@@ -46,16 +51,24 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
const installed = dungeonContentIsUsable(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
const mobCount = CANDIDATES.filter((candidate) => candidate.kind === "mob").length;
const bossCount = CANDIDATES.filter((candidate) => candidate.kind === "boss").length;
const queueEnabled = Boolean(DUNGEON && dungeonEnabledForQueue(queuePolicy, DUNGEON));
const launch = async (role: PartyRole) => {
if (!character || !DUNGEON?.available) return;
const policy = await loadDungeonQueuePolicy();
const selectedDungeon = randomRandomizedBiteSizedDungeon(undefined, Math.random,
(candidate) => dungeonEnabledForQueue(policy, candidate));
if (!selectedDungeon) {
setError("No Randomized Bite-Sized location is enabled for this queue.");
return;
}
if (!onlineGroup || !session || session.kind !== "account") {
const runId = globalThis.crypto.randomUUID();
setDownloading(true); setError("");
try {
await ensureRandomizedBiteSizedRunContent(runId);
enterDungeon(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, role, {
difficultyId: requireDungeonDefinition(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID).defaultDifficultyId,
enterDungeon(selectedDungeon.id, role, {
difficultyId: requireDungeonDefinition(selectedDungeon.id).defaultDifficultyId,
bossDifficulty,
freshRun: true,
runId,
@@ -74,8 +87,8 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
}
await startOnlineActivity(session, {
type: "dungeon",
contentId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
difficultyId: requireDungeonDefinition(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID).defaultDifficultyId,
contentId: selectedDungeon.id,
difficultyId: requireDungeonDefinition(selectedDungeon.id).defaultDifficultyId,
bossDifficulty,
partySize: 5,
fillWithAi: true,
@@ -122,17 +135,18 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
</section>
<section className="randomized-bite-sized-screen__launch">
<p className="front-kicker">Run setup</p>
<h2>{DUNGEON?.title ?? "Cellblock Shuffle"}</h2>
<h2>{queueEnabled ? DUNGEON?.title ?? "Cellblock Shuffle" : "No enabled location"}</h2>
<DungeonDifficultySlider value={bossDifficulty} onChange={setBossDifficulty} selectedId="" select={() => undefined} />
{!installed ? (
{!queueEnabled ? null : !installed ? (
<button className="front-button front-button--primary" type="button" disabled={downloading} onClick={() => void install()}>{downloading ? "Installing…" : "Install randomized dungeon content"}</button>
) : roles.length ? (
<div className="randomized-bite-sized-screen__roles">
<strong>Enter as</strong>
{roles.map((role) => <button className="front-button front-button--primary" key={role} type="button" disabled={!DUNGEON?.available || downloading} onClick={() => void launch(role)}>{downloading ? "Preparing run…" : PARTY_ROLE_LABELS[role]}</button>)}
{roles.map((role) => <button className="front-button front-button--primary" key={role} type="button" disabled={!DUNGEON?.available || !queueEnabled || downloading} onClick={() => void launch(role)}>{downloading ? "Preparing run…" : PARTY_ROLE_LABELS[role]}</button>)}
</div>
) : <p className="front-notice">Choose a specialization in your Class Hall before entering.</p>}
{!DUNGEON?.available && <p className="front-notice">This randomized route is not enterable in the current content build.</p>}
{!queueEnabled && <p className="front-notice">No Randomized Bite-Sized location is enabled in GM queue settings.</p>}
{(error || notice) && <p className="front-notice" role="status">{error || notice}</p>}
</section>
</main>
+20
View File
@@ -0,0 +1,20 @@
.dungeon-race-hud{position:absolute;z-index:42;top:8px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;gap:1px;min-width:180px;padding:5px 14px;border:1px solid #a79261;border-radius:9px;background:#091217e8;color:#f5e8ca;text-align:center;pointer-events:none;box-shadow:0 5px 18px #0009}
.dungeon-race-hud span{font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:#c2ac78}
.dungeon-race-hud strong{font-variant-numeric:tabular-nums;font-size:21px;line-height:1.1}
.dungeon-race-hud small{font-size:10px;white-space:nowrap}
.dungeon-race-wait,.dungeon-race-countdown{position:absolute;z-index:55;top:38%;left:50%;transform:translateX(-50%);padding:14px 22px;border:1px solid #ab945f;border-radius:12px;background:#091217ef;color:#f5e8ca;text-align:center;pointer-events:none}
.dungeon-race-countdown strong{display:block;font-size:42px}
.dungeon-race-toast{position:absolute;z-index:65;top:74px;left:50%;transform:translateX(-50%);display:flex;flex-direction:column;align-items:center;padding:9px 18px;border:1px solid #a79261;border-radius:9px;background:#101516f2;color:#ffe4a3;font-size:22px;font-weight:700;pointer-events:none}
.dungeon-race-toast--loss{border-color:#bd6b63;color:#ffb2a8}
.dungeon-race-toast small{font-size:11px;color:#f5e8ca}
@media(max-width:650px){.dungeon-race-hud{top:4px;min-width:145px;padding:3px 8px}.dungeon-race-hud strong{font-size:17px}.dungeon-race-toast{top:64px}}
.dungeon-race-screen{min-height:100%;padding:16px;color:#f5e8ca}
.dungeon-race-screen__header{display:flex;align-items:center;gap:18px}.dungeon-race-screen__header h1{margin:2px 0;font-size:25px}.dungeon-race-screen__header button{margin-left:auto}
.dungeon-race-screen__layout{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(280px,.9fr);gap:18px;margin-top:16px}
.dungeon-race-screen__setup,.dungeon-race-screen__board{padding:14px;border:1px solid #65583f;border-radius:10px;background:#081217cf}
.dungeon-race-screen__setup p{margin:0 0 12px}.dungeon-race-screen__choices{display:flex;flex-wrap:wrap;gap:7px;margin:9px 0}.dungeon-race-screen__choices .front-button{min-width:0;padding:8px 10px}
.dungeon-race-screen [aria-pressed=true]{border-color:#e4be70;background:#44371f;color:#ffe4a3}
.dungeon-race-screen label{display:flex;flex-direction:column;gap:4px;margin:8px 0;font-size:12px}.dungeon-race-screen select{max-width:100%;padding:8px;border:1px solid #766748;border-radius:6px;background:#121d23;color:#f5e8ca}
.dungeon-race-screen__practice{display:grid;grid-template-columns:1fr 1fr;gap:5px 10px}.dungeon-race-screen__practice small{grid-column:1/-1}
.dungeon-race-screen__board{overflow:auto}.dungeon-race-screen__board h2{margin:0 0 10px}.dungeon-race-screen__board table{width:100%;border-collapse:collapse;font-size:12px}.dungeon-race-screen__board th,.dungeon-race-screen__board td{padding:5px;text-align:left;border-bottom:1px solid #4d4a3b}
@media(max-width:760px){.dungeon-race-screen__layout{grid-template-columns:1fr}.dungeon-race-screen__header h1{font-size:19px}}