Release Healer Man 0.1.38
This commit is contained in:
1 parent
361734bc41
commit
b417b7c3c3
48 files changed
+1982
-90
No files matched your search
@@ -0,0 +1,69 @@
|
||||
import { useMemo, useRef, useState, type FormEvent } from "react";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { DUNGEON_REPORT_COMMENT_MAX_LENGTH, submitDungeonReport } from "../app/dungeonReports";
|
||||
import { dungeonById } from "../game/dungeonCatalog";
|
||||
import { useGameStore } from "../game/store";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
|
||||
export function DungeonIssueReportPanel() {
|
||||
const dungeonId = useGameStore((state) => state.activeDungeonId);
|
||||
const openOverlay = useGameStore((state) => state.openOverlay);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const [comment, setComment] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const busy = useRef(false);
|
||||
const form = useRef<HTMLFormElement>(null);
|
||||
const back = () => { if (!busy.current) openOverlay("pause"); };
|
||||
const canSubmit = Boolean(comment.trim()) && comment.length <= DUNGEON_REPORT_COMMENT_MAX_LENGTH && !submitting;
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "report-back", run: back, enabled: !submitting },
|
||||
{ id: "report-submit", run: () => form.current?.requestSubmit(), enabled: canSubmit && !submitted },
|
||||
], [canSubmit, openOverlay, submitted, submitting]);
|
||||
const controller = useMenuController(actions, { initialId: "report-back", onBack: back });
|
||||
|
||||
const submit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit || busy.current || submitted) return;
|
||||
busy.current = true;
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
await submitDungeonReport(session, dungeonId, comment);
|
||||
setSubmitted(true);
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : "Your report could not be submitted. Please try again.");
|
||||
} finally {
|
||||
busy.current = false;
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" role="presentation">
|
||||
<section className="pause-menu dungeon-issue-report" role="dialog" aria-modal="true" aria-labelledby="issue-report-title">
|
||||
<p className="eyebrow">Dungeon feedback</p>
|
||||
<h2 id="issue-report-title">Report an issue</h2>
|
||||
<p>{dungeonById(dungeonId)?.title ?? dungeonId}</p>
|
||||
{submitted ? <p role="status">Report submitted. You can follow its status in View Reports on the main menu.</p> : (
|
||||
<form ref={form} onSubmit={(event) => void submit(event)}>
|
||||
<label htmlFor="dungeon-issue-comment">Describe the issue</label>
|
||||
<textarea id="dungeon-issue-comment" autoFocus required maxLength={DUNGEON_REPORT_COMMENT_MAX_LENGTH}
|
||||
value={comment} disabled={submitting} onChange={(event) => setComment(event.target.value)}
|
||||
aria-describedby="dungeon-issue-count" placeholder="What happened, and what were you doing when it happened?" />
|
||||
<small id="dungeon-issue-count">{comment.length} / {DUNGEON_REPORT_COMMENT_MAX_LENGTH} characters</small>
|
||||
{error && <p role="alert" className="dungeon-report-error">{error}</p>}
|
||||
<ControllerButton controlId="report-submit" selectedId={controller.selectedId} select={controller.select}
|
||||
className="button button--primary" type="submit" disabled={!canSubmit}>
|
||||
{submitting ? "Submitting..." : "Submit report"}
|
||||
</ControllerButton>
|
||||
</form>
|
||||
)}
|
||||
<ControllerButton controlId="report-back" selectedId={controller.selectedId} select={controller.select}
|
||||
className="button" type="button" onClick={back} disabled={submitting}>Back to pause menu</ControllerButton>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
DUNGEON_REPORT_STATUSES, filterDungeonReports, sessionCanManageDungeonReports, updateDungeonReport,
|
||||
type DungeonReport, type DungeonReportStatus,
|
||||
} from "../app/dungeonReports";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { DualDisplayFrame } from "../components/DualDisplayFrame";
|
||||
import { DUNGEON_CATALOG, dungeonById } from "../game/dungeonCatalog";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
import { useDungeonReports } from "./useDungeonReports";
|
||||
|
||||
function ReportCard({ report, canManage, saving, onUpdate, selectedId, select }: {
|
||||
readonly report: DungeonReport;
|
||||
readonly canManage: boolean;
|
||||
readonly saving: boolean;
|
||||
readonly onUpdate: (report: DungeonReport, status: DungeonReportStatus) => void;
|
||||
readonly selectedId: string;
|
||||
readonly select: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<article className={`dungeon-report-card ${report.status === "Closed" ? "is-closed" : ""}`}>
|
||||
<header><h2>{dungeonById(report.dungeonId)?.title ?? report.dungeonId}</h2><span className="dungeon-report-status">{report.status}</span></header>
|
||||
<p className="dungeon-report-comment">{report.comment}</p>
|
||||
<small>Submitted by {report.submittedBy} · <time dateTime={new Date(report.createdAt).toISOString()}>{new Date(report.createdAt).toLocaleString()}</time></small>
|
||||
{report.updatedBy && <small>Updated by {report.updatedBy} · {new Date(report.updatedAt).toLocaleString()}</small>}
|
||||
{canManage && (
|
||||
<div className="dungeon-report-management">
|
||||
<label>Status
|
||||
<select aria-label={`Status for report ${report.id}`} value={report.status} disabled={saving}
|
||||
className={selectedId === `report-status-${report.id}` ? "is-controller-selected" : ""}
|
||||
onPointerEnter={() => select(`report-status-${report.id}`)}
|
||||
onChange={(event) => onUpdate(report, event.target.value as DungeonReportStatus)}>
|
||||
{DUNGEON_REPORT_STATUSES.map((status) => <option key={status} value={status}>{status}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{report.status !== "Closed" && <ControllerButton controlId={`report-close-${report.id}`} selectedId={selectedId} select={select}
|
||||
type="button" className="front-button" disabled={saving} onClick={() => onUpdate(report, "Closed")}>Close report</ControllerButton>}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function DungeonReportsScreen({ onClose }: { readonly onClose: () => void }) {
|
||||
const session = useShellStore((state) => state.session);
|
||||
const { snapshot, loading, error, refresh } = useDungeonReports();
|
||||
const [status, setStatus] = useState<DungeonReportStatus | "all">("all");
|
||||
const [dungeonId, setDungeonId] = useState("all");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [updateError, setUpdateError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const busy = useRef(false);
|
||||
const canManage = sessionCanManageDungeonReports(session);
|
||||
const reports = filterDungeonReports(snapshot?.reports ?? [], status, dungeonId);
|
||||
const dungeonOptions = useMemo(() => [...DUNGEON_CATALOG].sort((a, b) => a.title.localeCompare(b.title)), []);
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
{ id: "reports-back", run: onClose, enabled: !saving },
|
||||
{ id: "reports-filter-status", run: () => {
|
||||
const options = ["all", ...DUNGEON_REPORT_STATUSES] as const;
|
||||
setStatus(options[(options.indexOf(status) + 1) % options.length]);
|
||||
} },
|
||||
{ id: "reports-filter-dungeon", run: () => {
|
||||
const options = ["all", ...dungeonOptions.map((dungeon) => dungeon.id)];
|
||||
setDungeonId(options[(options.indexOf(dungeonId) + 1) % options.length]);
|
||||
} },
|
||||
{ id: "reports-clear-filters", run: () => { setStatus("all"); setDungeonId("all"); } },
|
||||
{ id: "reports-refresh", run: () => void refresh(), enabled: !loading && !saving },
|
||||
...(canManage ? reports.flatMap((report) => [
|
||||
{ id: `report-status-${report.id}`, enabled: !saving, run: () => {
|
||||
const nextStatus = DUNGEON_REPORT_STATUSES[(DUNGEON_REPORT_STATUSES.indexOf(report.status) + 1) % DUNGEON_REPORT_STATUSES.length];
|
||||
void update(report, nextStatus);
|
||||
} },
|
||||
...(report.status === "Closed" ? [] : [{ id: `report-close-${report.id}`, enabled: !saving, run: () => void update(report, "Closed") }]),
|
||||
]) : []),
|
||||
], [canManage, dungeonId, dungeonOptions, loading, onClose, refresh, reports, saving, session, status]);
|
||||
const controller = useMenuController(actions, { initialId: "reports-back", onBack: saving ? undefined : onClose });
|
||||
const update = async (report: DungeonReport, nextStatus: DungeonReportStatus) => {
|
||||
if (!session || !canManage || busy.current) return;
|
||||
busy.current = true;
|
||||
setSaving(true);
|
||||
setUpdateError("");
|
||||
setMessage("");
|
||||
try {
|
||||
await updateDungeonReport(session, report, nextStatus);
|
||||
setMessage(`Report status updated to ${nextStatus}.`);
|
||||
} catch (failure) {
|
||||
setUpdateError(failure instanceof Error ? failure.message : "The status could not be updated.");
|
||||
} finally {
|
||||
await refresh();
|
||||
busy.current = false;
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DualDisplayFrame contextLabel="Dungeon reports" top={
|
||||
<FrontSurface className="dungeon-reports-screen" ariaLabel="Dungeon reports">
|
||||
<header className="dungeon-reports-header">
|
||||
<BrandMark compact />
|
||||
<div><span>Main menu / Dungeon feedback</span><h1>View Reports</h1></div>
|
||||
<ControllerButton controlId="reports-back" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button" type="button" onClick={onClose} disabled={saving}>Back to main menu</ControllerButton>
|
||||
</header>
|
||||
<main className="dungeon-reports-main">
|
||||
<div className="dungeon-report-filters">
|
||||
<label>Status
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value as DungeonReportStatus | "all")}
|
||||
className={controller.selectedId === "reports-filter-status" ? "is-controller-selected" : ""}
|
||||
onPointerEnter={() => controller.select("reports-filter-status")}>
|
||||
<option value="all">All statuses</option>
|
||||
{DUNGEON_REPORT_STATUSES.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>Dungeon
|
||||
<select value={dungeonId} onChange={(event) => setDungeonId(event.target.value)}
|
||||
className={controller.selectedId === "reports-filter-dungeon" ? "is-controller-selected" : ""}
|
||||
onPointerEnter={() => controller.select("reports-filter-dungeon")}>
|
||||
<option value="all">All dungeons</option>
|
||||
{dungeonOptions.map((dungeon) => (
|
||||
<option key={dungeon.id} value={dungeon.id}>{dungeon.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<ControllerButton controlId="reports-clear-filters" selectedId={controller.selectedId} select={controller.select}
|
||||
type="button" className="front-button" onClick={() => { setStatus("all"); setDungeonId("all"); }}>Clear filters</ControllerButton>
|
||||
<ControllerButton controlId="reports-refresh" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button" type="button" disabled={loading || saving} onClick={() => void refresh()}>
|
||||
{loading ? "Refreshing..." : "Refresh"}
|
||||
</ControllerButton>
|
||||
</div>
|
||||
<p className="dungeon-reports-summary">{reports.length} of {snapshot?.reports.length ?? 0} reports · {canManage ? "GM status controls enabled" : "All players can view reports. GMs manage their status."}</p>
|
||||
{(error || updateError) && <p role="alert" className="front-notice">{updateError || error}{snapshot && error ? " Showing the last loaded reports." : ""}</p>}
|
||||
{message && <p role="status" className="front-notice">{message}</p>}
|
||||
<section className="dungeon-report-list" aria-label="Reports" aria-busy={loading || saving}>
|
||||
{loading && !snapshot ? <p role="status">Loading reports...</p> : snapshot && reports.length === 0 ? (
|
||||
<p>{snapshot.reports.length ? "No reports match these filters." : "No dungeon reports have been submitted yet."}</p>
|
||||
) : reports.map((report) => <ReportCard key={report.id} report={report} canManage={canManage} saving={saving}
|
||||
onUpdate={(item, nextStatus) => void update(item, nextStatus)} selectedId={controller.selectedId} select={controller.select} />)}
|
||||
</section>
|
||||
</main>
|
||||
<ControllerLegend back />
|
||||
</FrontSurface>
|
||||
} bottom={<LazyLocalCompanionPanel />} />
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,8 @@ import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import { DUNGEON_RELEASE_STAGE_LABELS } from "../game/dungeonReleaseStage";
|
||||
import { useDungeonReports } from "./useDungeonReports";
|
||||
|
||||
installDungeonShellRuntime();
|
||||
|
||||
@@ -50,6 +52,7 @@ interface PendingDungeonDownload {
|
||||
}
|
||||
|
||||
export function DungeonSelectScreen() {
|
||||
const reports = useDungeonReports();
|
||||
const contentState = useContentManagerSnapshot();
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const rosterCharacter = useShellStore(selectedCharacter);
|
||||
@@ -253,19 +256,24 @@ export function DungeonSelectScreen() {
|
||||
controlId={`dungeon-${dungeon.id}`}
|
||||
selectedId={controller.selectedId}
|
||||
select={controller.select}
|
||||
className={`dungeon-select-row ${dungeon.id === selectedDungeon?.id ? "is-active" : ""}`}
|
||||
className={`dungeon-select-row ${dungeon.id === selectedDungeon?.id ? "is-active" : ""} ${(reports.snapshot?.openCounts[dungeon.id] ?? 0) > 0 ? "has-open-reports" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={dungeon.id === selectedDungeon?.id}
|
||||
onClick={() => selectDungeon(dungeon.id)}
|
||||
>
|
||||
<b aria-hidden="true">{String(index + 1).padStart(2, "0")}</b>
|
||||
<span><strong>{dungeon.title}</strong><small>{dungeon.entranceName}</small></span>
|
||||
<span><strong>{dungeon.title}</strong><small>{DUNGEON_RELEASE_STAGE_LABELS[dungeon.releaseStage]} · {dungeon.entranceName}</small></span>
|
||||
{(reports.snapshot?.openCounts[dungeon.id] ?? 0) > 0 && (
|
||||
<sup className="dungeon-open-report-count" aria-label={`${reports.snapshot!.openCounts[dungeon.id]} open reports`}
|
||||
title={`${reports.snapshot!.openCounts[dungeon.id]} open reports`}>{reports.snapshot!.openCounts[dungeon.id]}</sup>
|
||||
)}
|
||||
<em>{dungeon.available
|
||||
? dungeonContentIsUsable(dungeon.id) ? "Ready" : "Download required"
|
||||
: "Coming soon"}</em>
|
||||
</ControllerButton>
|
||||
))}
|
||||
</div>
|
||||
{reports.error && <small role="status" className="dungeon-report-load-error">Report counts unavailable. {reports.snapshot ? "Showing the last loaded counts." : ""}</small>}
|
||||
<ControllerButton
|
||||
controlId="dungeon-back"
|
||||
selectedId={controller.selectedId}
|
||||
@@ -295,6 +303,7 @@ export function DungeonSelectScreen() {
|
||||
<section className="dungeon-select-copy">
|
||||
<p className="front-kicker">{canStart ? "Playable expedition" : selectedDungeon.available ? "Content required" : "In development"}</p>
|
||||
<h2>{selectedDungeon.title}</h2>
|
||||
<span className="dungeon-release-stage">{DUNGEON_RELEASE_STAGE_LABELS[selectedDungeon.releaseStage]}</span>
|
||||
<p>{selectedDungeon.summary}</p>
|
||||
{!canStart && selectedDungeon.availabilityReason && (
|
||||
<p className="front-notice" role="status">{selectedDungeon.availabilityReason}</p>
|
||||
|
||||
+8
-2
@@ -13,7 +13,7 @@ import { useShellStore } from "../app/shellStore";
|
||||
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
|
||||
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
|
||||
import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore";
|
||||
import { respawnDefeatedPlayer } from "../game/deathRespawn";
|
||||
import { partyFightStillActive, respawnDefeatedPlayer } from "../game/deathRespawn";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { resurrectManastormSession } from "../game/manastormSession";
|
||||
import { resolveStagePresentation } from "../game/manastormStagePresentation";
|
||||
@@ -86,12 +86,14 @@ export function DefeatedPrompt({
|
||||
onReturn,
|
||||
sharedOnline = false,
|
||||
checkpoints = [],
|
||||
partyStillFighting = false,
|
||||
}: {
|
||||
gameMode: "dungeon" | "manastorm";
|
||||
resurrectionCharges: number;
|
||||
onReturn: (checkpointId?: string) => void;
|
||||
sharedOnline?: boolean;
|
||||
checkpoints?: readonly ResurrectionCheckpoint[];
|
||||
partyStillFighting?: boolean;
|
||||
}) {
|
||||
const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance");
|
||||
const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance";
|
||||
@@ -126,7 +128,9 @@ export function DefeatedPrompt({
|
||||
<small>No shared resurrection charges remain. A living ally must resurrect you.</small>
|
||||
)}
|
||||
{gameMode === "dungeon" && !sharedOnline ? (
|
||||
<small>Respawning resets the current encounter and restores the party.</small>
|
||||
<small>{partyStillFighting
|
||||
? "Your party will keep fighting where they are. Use Recall if you want to regroup."
|
||||
: "Respawning resets the current encounter and restores the party."}</small>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
@@ -204,6 +208,7 @@ export function Hud() {
|
||||
: null);
|
||||
const selfSelected = usePartyStore((state) => state.selfSelected);
|
||||
const partyMembers = usePartyStore((state) => state.members);
|
||||
const partyStillFighting = useCombatStore((state) => partyFightStillActive(partyMembers, state.mobs, state.actors));
|
||||
const playerRole = usePartyStore((state) => state.playerRole);
|
||||
const settings = useCombatStore((state) => state.settings);
|
||||
const onlineSession = useOnlineSessionStore((state) => state.snapshot);
|
||||
@@ -516,6 +521,7 @@ export function Hud() {
|
||||
resurrectionCharges={resurrectionCharges}
|
||||
onReturn={returnFromDefeat}
|
||||
checkpoints={resurrectionCheckpoints}
|
||||
partyStillFighting={partyStillFighting}
|
||||
sharedOnline={onlineSession !== null}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ContentManagerScreen } from "./ContentManagerScreen";
|
||||
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
|
||||
import { DungeonReportsScreen } from "./DungeonReportsScreen";
|
||||
|
||||
const GAME_MODES = [
|
||||
{
|
||||
@@ -64,6 +65,16 @@ const GAME_MODES = [
|
||||
|
||||
export function MainMenuScreen() {
|
||||
const [contentManagerOpen, setContentManagerOpen] = useState(false);
|
||||
const [reportsOpen, setReportsOpen] = useState(false);
|
||||
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
|
||||
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
|
||||
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} />;
|
||||
}
|
||||
|
||||
function MainMenuDestinations({ onOpenDownloads, onOpenReports }: {
|
||||
readonly onOpenDownloads: () => void;
|
||||
readonly onOpenReports: () => void;
|
||||
}) {
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const rosterCharacter = useShellStore(selectedCharacter);
|
||||
const openDungeons = useShellStore((state) => state.openDungeons);
|
||||
@@ -95,7 +106,7 @@ export function MainMenuScreen() {
|
||||
}] : []),
|
||||
{
|
||||
id: "mode-downloads",
|
||||
run: () => setContentManagerOpen(true),
|
||||
run: onOpenDownloads,
|
||||
neighbors: { left: SHELVED_MODES_ENABLED ? "mode-manastorms" : "mode-dungeons", down: "back-to-roster" },
|
||||
},
|
||||
{
|
||||
@@ -105,7 +116,8 @@ export function MainMenuScreen() {
|
||||
},
|
||||
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
|
||||
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
|
||||
], [character, isGm, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
{ id: "mode-reports", run: onOpenReports },
|
||||
], [character, isGm, onOpenDownloads, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: "mode-dungeons",
|
||||
onBack: returnToCharacters,
|
||||
@@ -114,8 +126,6 @@ export function MainMenuScreen() {
|
||||
const race = character ? raceById(character.raceId) : null;
|
||||
const characterClass = character ? classById(character.classId) : null;
|
||||
|
||||
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
|
||||
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
contextLabel="Main menu details"
|
||||
@@ -163,6 +173,12 @@ export function MainMenuScreen() {
|
||||
</header>
|
||||
<OnlineGroupPanel />
|
||||
<nav className="main-menu-mode-grid" aria-label="Game modes">
|
||||
<ControllerButton controlId="mode-reports" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenReports}>
|
||||
<b aria-hidden="true">!</b>
|
||||
<span><strong>View Reports</strong><small>Browse dungeon issues and follow their status.</small></span>
|
||||
<em>Open</em>
|
||||
</ControllerButton>
|
||||
{isGm && (
|
||||
<ControllerButton
|
||||
controlId="mode-gm-tools"
|
||||
@@ -204,7 +220,7 @@ export function MainMenuScreen() {
|
||||
: mode.id === "arenas"
|
||||
? openArenas
|
||||
: mode.id === "downloads"
|
||||
? () => setContentManagerOpen(true)
|
||||
? onOpenDownloads
|
||||
: openDungeons}
|
||||
>
|
||||
<b aria-hidden="true">{mode.number}</b>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ControllerButton } from "./ControllerButton";
|
||||
import { sessionCanUseShelvedGmTools } from "../app/releaseFeatures";
|
||||
import { useInputBindingLabels } from "../input/useInputBindingLabels";
|
||||
import { INPUT_ACTION_BY_ID } from "../input/keybinds";
|
||||
import { DungeonIssueReportPanel } from "./DungeonIssueReportPanel";
|
||||
|
||||
function PauseDialog() {
|
||||
const bindingLabel = useInputBindingLabels();
|
||||
@@ -47,6 +48,7 @@ function PauseDialog() {
|
||||
{ id: "pause-keybinds", run: () => openOverlay("keybinds") },
|
||||
{ id: "pause-options", run: () => openOverlay("options") },
|
||||
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
|
||||
...(gameMode === "dungeon" ? [{ id: "pause-report-issue", run: () => openOverlay("report-issue") }] : []),
|
||||
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
|
||||
...(gameMode === "manastorm"
|
||||
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
|
||||
@@ -84,6 +86,7 @@ function PauseDialog() {
|
||||
{menuButton("pause-keybinds", "Keybinds", () => openOverlay("keybinds"))}
|
||||
{menuButton("pause-options", "Options", () => openOverlay("options"))}
|
||||
{menuButton("pause-ui-layout", "Edit UI & templates", () => openOverlay("ui-layout"))}
|
||||
{gameMode === "dungeon" ? menuButton("pause-report-issue", "Report an issue", () => openOverlay("report-issue")) : null}
|
||||
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
|
||||
{gameMode === "manastorm"
|
||||
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
|
||||
@@ -103,6 +106,7 @@ function PauseDialog() {
|
||||
}
|
||||
|
||||
export function PauseMenu() {
|
||||
const open = useGameStore((state) => state.overlay === "pause");
|
||||
return open ? <PauseDialog /> : null;
|
||||
const overlay = useGameStore((state) => state.overlay);
|
||||
if (overlay === "report-issue") return <DungeonIssueReportPanel />;
|
||||
return overlay === "pause" ? <PauseDialog /> : null;
|
||||
}
|
||||
@@ -14,6 +14,17 @@ describe("defeated prompt", () => {
|
||||
|
||||
expect(markup).toContain("wait for a party member to resurrect you");
|
||||
expect(markup).toContain("Respawn at entrance");
|
||||
expect(markup).toContain("Respawning resets the current encounter and restores the party.");
|
||||
});
|
||||
|
||||
it("explains player-only resurrection and optional Recall during an ongoing party fight", () => {
|
||||
const markup = renderToStaticMarkup(createElement(DefeatedPrompt, {
|
||||
gameMode: "dungeon", resurrectionCharges: 0, partyStillFighting: true, onReturn: () => undefined,
|
||||
}));
|
||||
expect(markup).toContain("Your party will keep fighting where they are.");
|
||||
expect(markup).toContain("Use Recall if you want to regroup.");
|
||||
expect(markup).toContain("Respawn at entrance");
|
||||
expect(markup).not.toContain("resets the current encounter");
|
||||
});
|
||||
|
||||
it("does not offer a Manastorm resurrection when no shared charges remain", () => {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
:root { --report-orange: #f1a044; }
|
||||
.dungeon-select-row { position: relative; }
|
||||
.dungeon-select-row.has-open-reports { border-color: var(--report-orange); }
|
||||
.dungeon-select-row.has-open-reports.is-controller-selected { box-shadow: 0 0 0 2px rgba(241,160,68,.5) !important; }
|
||||
.dungeon-select-row.has-open-reports.is-controller-selected, .dungeon-select-row.has-open-reports:focus-visible { outline-color: var(--report-orange) !important; }
|
||||
.dungeon-open-report-count {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 4px;
|
||||
display: grid;
|
||||
min-width: 19px;
|
||||
height: 19px;
|
||||
padding: 0 4px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: var(--report-orange);
|
||||
color: #211608;
|
||||
font: 700 11px/1 sans-serif;
|
||||
}
|
||||
.dungeon-release-stage {
|
||||
display: inline-block;
|
||||
margin-bottom: 9px;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid var(--front-line);
|
||||
border-radius: 5px;
|
||||
color: var(--front-gold);
|
||||
font-size: 10px;
|
||||
}
|
||||
.dungeon-report-load-error { margin: 0 0 5px; color: var(--report-orange); font-size: 10px; }
|
||||
.dungeon-issue-report form { display: grid; gap: 10px; margin-bottom: 12px; }
|
||||
.dungeon-issue-report textarea {
|
||||
width: 100%;
|
||||
min-height: 140px;
|
||||
max-height: 30vh;
|
||||
padding: 12px;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--edge);
|
||||
border-radius: 8px;
|
||||
background: rgba(0,0,0,.25);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.dungeon-issue-report form > small { color: var(--muted); text-align: right; }
|
||||
.dungeon-issue-report > .button { width: 100%; }
|
||||
.dungeon-report-error { margin: 0; color: var(--report-orange); }
|
||||
.dungeon-reports-screen { display: grid; grid-template-rows: auto minmax(0,1fr) auto; gap: 14px; }
|
||||
.dungeon-reports-header { display: flex; align-items: center; gap: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--front-line); }
|
||||
.dungeon-reports-header > div:nth-child(2) { flex: 1; }
|
||||
.dungeon-reports-header > div > span { color: var(--front-dim); font-size: 10px; }
|
||||
.dungeon-reports-header h1 { margin: 3px 0 0; font: 26px Georgia, serif; }
|
||||
.dungeon-reports-main { display: flex; min-height: 0; flex-direction: column; }
|
||||
.dungeon-report-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; }
|
||||
.dungeon-report-filters label, .dungeon-report-management label { display: grid; min-width: 0; gap: 5px; color: var(--front-dim); font-size: 12px; }
|
||||
.dungeon-report-filters select, .dungeon-report-management select {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
min-height: 40px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--front-line);
|
||||
border-radius: 7px;
|
||||
background: #182015;
|
||||
color: var(--front-cream);
|
||||
font: inherit;
|
||||
}
|
||||
.dungeon-report-filters > label:nth-child(2) { flex: 1; }
|
||||
.dungeon-reports-summary { color: var(--front-dim); font-size: 12px; }
|
||||
.dungeon-report-list { display: grid; min-height: 0; align-content: start; gap: 12px; overflow: auto; padding: 2px 4px 10px 0; }
|
||||
.dungeon-report-card { padding: 16px; border: 1px solid var(--report-orange); border-radius: 9px; background: rgba(255,255,255,.025); }
|
||||
.dungeon-report-card.is-closed { border-color: var(--front-line); }
|
||||
.dungeon-report-card header { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||
.dungeon-report-card h2 { flex: 1; margin: 0; font: 19px Georgia, serif; }
|
||||
.dungeon-report-status { padding: 4px 8px; border-radius: 5px; background: rgba(241,160,68,.12); color: var(--report-orange); font-size: 12px; }
|
||||
.is-closed .dungeon-report-status { color: var(--front-dim); background: rgba(255,255,255,.05); }
|
||||
.dungeon-report-comment { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; line-height: 1.5; }
|
||||
.dungeon-report-card > small { display: block; margin-top: 5px; color: var(--front-dim); font-size: 11px; }
|
||||
.dungeon-report-management { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-top: 14px; }
|
||||
.dungeon-reports-screen > .controller-legend { position: static; justify-self: end; }
|
||||
.dungeon-issue-report textarea:focus-visible, .dungeon-reports-screen select:focus-visible { outline: 2px solid var(--report-orange); outline-offset: 2px; }
|
||||
.dungeon-reports-screen select.is-controller-selected { outline: 2px solid var(--front-gold); outline-offset: 2px; }
|
||||
@media (max-width: 600px) {
|
||||
.dungeon-reports-header { gap: 10px; }
|
||||
.dungeon-reports-header > .front-brand { display: none; }
|
||||
.dungeon-reports-header h1 { font-size: 21px; }
|
||||
.dungeon-report-filters { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); }
|
||||
.dungeon-report-card { padding: 12px; }
|
||||
.dungeon-report-card h2 { font-size: 17px; }
|
||||
.dungeon-reports-screen > .controller-legend { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchDungeonReports, type DungeonReportSnapshot } from "../app/dungeonReports";
|
||||
|
||||
export function useDungeonReports() {
|
||||
const [snapshot, setSnapshot] = useState<DungeonReportSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const sequence = useRef(0);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const request = ++sequence.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const next = await fetchDungeonReports();
|
||||
if (request !== sequence.current) return;
|
||||
setSnapshot(next);
|
||||
setError("");
|
||||
} catch (failure) {
|
||||
if (request === sequence.current) setError(failure instanceof Error ? failure.message : "Reports could not be loaded.");
|
||||
} finally {
|
||||
if (request === sequence.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshVisible = () => { if (document.visibilityState === "visible") void refresh(); };
|
||||
void refresh();
|
||||
const timer = window.setInterval(refreshVisible, 30_000);
|
||||
window.addEventListener("focus", refreshVisible);
|
||||
document.addEventListener("visibilitychange", refreshVisible);
|
||||
return () => {
|
||||
sequence.current += 1;
|
||||
window.clearInterval(timer);
|
||||
window.removeEventListener("focus", refreshVisible);
|
||||
document.removeEventListener("visibilitychange", refreshVisible);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
return { snapshot, loading, error, refresh };
|
||||
}
|
||||
Reference in new issue
Block a user