forked from phenom/healer-man
Release Healer Man 0.1.58
This commit is contained in:
1 parent
14e105642c
commit
246b4832b0
38 files changed
+952
-93
No files matched your search
@@ -1,19 +1,20 @@
|
||||
import { useCompanionPartyPreferences } from "../game/companionPartyPreferences";
|
||||
import { useCompanionSpecReadinessStore } from "../game/companionSpecReadinessStore";
|
||||
|
||||
export function CompanionPoolControls({ onOpenReadiness, compact = false }: {
|
||||
export function CompanionPoolControls({ onOpenReadiness, compact = false, readyOnly = false }: {
|
||||
readonly onOpenReadiness: () => void;
|
||||
readonly compact?: boolean;
|
||||
readonly readyOnly?: boolean;
|
||||
}) {
|
||||
const policy = useCompanionSpecReadinessStore((state) => state.config);
|
||||
const includeBetaSpecs = useCompanionPartyPreferences((state) => state.includeBetaSpecs);
|
||||
const setIncludeBetaSpecs = useCompanionPartyPreferences((state) => state.setIncludeBetaSpecs);
|
||||
const betaCount = Object.values(policy.statuses).filter((status) => status === "beta").length;
|
||||
return <div className={`companion-pool-controls ${compact ? "companion-pool-controls--compact" : ""}`}>
|
||||
<label title={betaCount ? "Allow certified beta specializations in random NPC seats." : "No beta specs available"}>
|
||||
{readyOnly ? <span><strong>Ready NPCs only</strong><small>Open seats use the published Ready specialization pool.</small></span> : <label title={betaCount ? "Allow certified beta specializations in random NPC seats." : "No beta specs available"}>
|
||||
<input type="checkbox" checked={includeBetaSpecs && betaCount > 0} disabled={betaCount === 0} onChange={(event) => setIncludeBetaSpecs(event.target.checked)} />
|
||||
<span><strong>Include Beta Specs</strong><small>{betaCount ? `${betaCount} beta ${betaCount === 1 ? "spec" : "specs"} available` : "No beta specs available"}</small></span>
|
||||
</label>
|
||||
</label>}
|
||||
<button className="text-action" type="button" onClick={onOpenReadiness}>View companion readiness</button>
|
||||
</div>;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
import { CompanionPoolControls } from "./CompanionPoolControls";
|
||||
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
|
||||
import { RandomizedCreatureReadinessScreen } from "./RandomizedCreatureReadinessScreen";
|
||||
|
||||
const COOLDOWN_MECHANICS = BOSS_TESTING_CATALOG.flatMap((boss) => {
|
||||
const entity = requireDungeonDefinition(boss.dungeonId).entities[boss.entityId];
|
||||
@@ -62,6 +63,7 @@ export function DungeonSimulationAdminScreen() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [readinessOpen, setReadinessOpen] = useState(false);
|
||||
const [creatureReadinessOpen, setCreatureReadinessOpen] = useState(false);
|
||||
const [selectedCooldownMechanic, setSelectedCooldownMechanic] = useState(COOLDOWN_MECHANICS[0]?.key ?? "");
|
||||
const cooldownDraft = useCompanionCooldownPolicyStore((state) => state.draft);
|
||||
const cooldownRevision = useCompanionCooldownPolicyStore((state) => state.revision);
|
||||
@@ -212,6 +214,7 @@ export function DungeonSimulationAdminScreen() {
|
||||
};
|
||||
|
||||
if (readinessOpen) return <CompanionReadinessScreen onClose={() => setReadinessOpen(false)} />;
|
||||
if (creatureReadinessOpen) return <RandomizedCreatureReadinessScreen onClose={() => setCreatureReadinessOpen(false)} />;
|
||||
|
||||
return (
|
||||
<FrontSurface className="dungeon-sim-admin" ariaLabel="Dungeon simulation route review">
|
||||
@@ -219,6 +222,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={() => 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>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@ 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 CompanionReadinessScreen = lazy(() => import("./CompanionReadinessScreen").then((module) => ({ default: module.CompanionReadinessScreen })));
|
||||
const RandomizedBiteSizedScreen = lazy(() => import("./RandomizedBiteSizedScreen").then((module) => ({ default: module.RandomizedBiteSizedScreen })));
|
||||
|
||||
const GAME_MODES = [
|
||||
{
|
||||
@@ -25,6 +26,13 @@ const GAME_MODES = [
|
||||
description: "Choose an expedition and lead your party into the depths.",
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: "randomized-bite-sized",
|
||||
number: "02",
|
||||
name: "Randomized Bite-Sized",
|
||||
description: "Run a short dungeon with every mob and the final boss rerolled.",
|
||||
available: true,
|
||||
},
|
||||
...(SHELVED_MODES_ENABLED ? [{
|
||||
id: "manastorms" as const,
|
||||
number: "02",
|
||||
@@ -75,24 +83,27 @@ export function MainMenuScreen() {
|
||||
const [bossTestingOpen, setBossTestingOpen] = useState(false);
|
||||
const [balanceLabOpen, setBalanceLabOpen] = useState(false);
|
||||
const [readinessOpen, setReadinessOpen] = useState(false);
|
||||
const [randomizedBiteSizedOpen, setRandomizedBiteSizedOpen] = 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 (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 (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)} onOpenReadiness={() => setReadinessOpen(true)} onOpenEditor={setEditor} />;
|
||||
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} onOpenCards={() => setCardsOpen(true)} onOpenBossTesting={() => setBossTestingOpen(true)} onOpenBalanceLab={() => setBalanceLabOpen(true)} onOpenReadiness={() => setReadinessOpen(true)} onOpenRandomizedBiteSized={() => setRandomizedBiteSizedOpen(true)} onOpenEditor={setEditor} />;
|
||||
}
|
||||
|
||||
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenReadiness, onOpenEditor }: {
|
||||
function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onOpenBossTesting, onOpenBalanceLab, onOpenReadiness, onOpenRandomizedBiteSized, onOpenEditor }: {
|
||||
readonly onOpenDownloads: () => void;
|
||||
readonly onOpenReports: () => void;
|
||||
readonly onOpenCards: () => void;
|
||||
readonly onOpenBossTesting: () => void;
|
||||
readonly onOpenBalanceLab: () => void;
|
||||
readonly onOpenReadiness: () => void;
|
||||
readonly onOpenRandomizedBiteSized: () => void;
|
||||
readonly onOpenEditor: (editor: MainMenuEditor) => void;
|
||||
}) {
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
@@ -113,12 +124,17 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
{
|
||||
id: "mode-dungeons",
|
||||
run: openDungeons,
|
||||
neighbors: { right: "mode-manastorms", down: "back-to-roster" },
|
||||
neighbors: { right: "mode-randomized-bite-sized", down: "back-to-roster" },
|
||||
},
|
||||
{
|
||||
id: "mode-randomized-bite-sized",
|
||||
run: onOpenRandomizedBiteSized,
|
||||
neighbors: { left: "mode-dungeons", right: SHELVED_MODES_ENABLED ? "mode-manastorms" : "mode-downloads", down: "back-to-roster" },
|
||||
},
|
||||
...(SHELVED_MODES_ENABLED ? [{
|
||||
id: "mode-manastorms",
|
||||
run: openManastorms,
|
||||
neighbors: { left: "mode-dungeons", down: "back-to-roster" },
|
||||
neighbors: { left: "mode-randomized-bite-sized", down: "back-to-roster" },
|
||||
}, {
|
||||
id: "mode-arenas",
|
||||
run: openArenas,
|
||||
@@ -148,7 +164,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
{ id: "mode-cards", run: onOpenCards },
|
||||
{ id: "mode-boss-testing", run: onOpenBossTesting },
|
||||
{ id: "mode-companion-readiness", run: onOpenReadiness },
|
||||
], [character, isGm, onOpenBalanceLab, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
], [character, isGm, onOpenBalanceLab, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: "mode-dungeons",
|
||||
onBack: returnToCharacters,
|
||||
@@ -292,6 +308,8 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
? openArenas
|
||||
: mode.id === "downloads"
|
||||
? onOpenDownloads
|
||||
: mode.id === "randomized-bite-sized"
|
||||
? onOpenRandomizedBiteSized
|
||||
: openDungeons}
|
||||
>
|
||||
<b aria-hidden="true">{mode.number}</b>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type PartyRole,
|
||||
} from "../game/partyRoles";
|
||||
|
||||
export function OnlineGroupPanel() {
|
||||
export function OnlineGroupPanel({ forceAiFill = false }: { readonly forceAiFill?: boolean }) {
|
||||
const [username, setUsername] = useState("");
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const session = useShellStore((state) => state.session);
|
||||
@@ -121,12 +121,14 @@ export function OnlineGroupPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{leader && group && !group.activity && (
|
||||
{leader && group && !group.activity && (forceAiFill ? (
|
||||
<p className="front-notice">Every ready real player enters together; Ready NPCs fill the remaining seats to five.</p>
|
||||
) : (
|
||||
<label className="online-group-ai-toggle">
|
||||
<input type="checkbox" checked={fillWithAi} onChange={(event) => setFillWithAi(event.target.checked)} />
|
||||
<span><strong>Fill open slots with AI</strong><small>Turn off to enter with only grouped players.</small></span>
|
||||
</label>
|
||||
)}
|
||||
))}
|
||||
|
||||
{(!group || leader) && (
|
||||
<form className="online-group-invite-form" onSubmit={submitInvite}>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useState } from "react";
|
||||
import { selectedCharacter, useShellStore } from "../app/shellStore";
|
||||
import { installDungeonShellRuntime } from "../app/dungeonShellRuntime";
|
||||
import { useOnlineGroupStore } from "../app/onlineGroupStore";
|
||||
import { specializationById } from "../game/specializationCatalog";
|
||||
import { partyRolesForClass, PARTY_ROLE_LABELS, type PartyRole } from "../game/partyStore";
|
||||
import { dungeonContentIsUsable, installContentPack } from "../content/contentManager";
|
||||
import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
import {
|
||||
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
STORMWIND_STOCKADE_DUNGEON_ID,
|
||||
dungeonContentPackId,
|
||||
} from "../game/dungeonContentSource";
|
||||
import { randomizedBiteSizedCreatureCatalog } from "../game/dungeons/randomizedBiteSizedStockades";
|
||||
import { dungeonById } from "../game/dungeonCatalog";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
import { DungeonDifficultySlider } from "./DungeonDifficultySlider";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { CompanionPoolControls } from "./CompanionPoolControls";
|
||||
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
|
||||
|
||||
installDungeonShellRuntime();
|
||||
|
||||
const DUNGEON = dungeonById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
|
||||
const CANDIDATES = randomizedBiteSizedCreatureCatalog(requireDungeonDefinition(STORMWIND_STOCKADE_DUNGEON_ID));
|
||||
|
||||
export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () => void }) {
|
||||
useContentManagerSnapshot();
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const rosterCharacter = useShellStore(selectedCharacter);
|
||||
const enterDungeon = useShellStore((state) => state.enterDungeon);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const notice = useShellStore((state) => state.notice);
|
||||
const onlineGroup = useOnlineGroupStore((state) => state.snapshot.group);
|
||||
const updateOnlineMember = useOnlineGroupStore((state) => state.updateMember);
|
||||
const startOnlineActivity = useOnlineGroupStore((state) => state.startActivity);
|
||||
const [bossDifficulty, setBossDifficulty] = useState(1);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [readinessOpen, setReadinessOpen] = useState(false);
|
||||
const character = activeCharacter ?? rosterCharacter;
|
||||
const specialization = specializationById(character?.specializationId);
|
||||
const roles = character
|
||||
? character.categoryId === "rom" ? partyRolesForClass(character.classId) : specialization ? [specialization.role] : []
|
||||
: [];
|
||||
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 launch = async (role: PartyRole) => {
|
||||
if (!character || !DUNGEON?.available) return;
|
||||
if (!onlineGroup || !session || session.kind !== "account") {
|
||||
enterDungeon(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, role, {
|
||||
difficultyId: requireDungeonDefinition(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID).defaultDifficultyId,
|
||||
bossDifficulty,
|
||||
freshRun: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!await updateOnlineMember(session, character, role)) return;
|
||||
if (onlineGroup.leaderAccountId !== session.ownerId) {
|
||||
useShellStore.getState().setNotice(`Ready as ${PARTY_ROLE_LABELS[role]}. Waiting for the group leader to start.`);
|
||||
return;
|
||||
}
|
||||
await startOnlineActivity(session, {
|
||||
type: "dungeon",
|
||||
contentId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
difficultyId: requireDungeonDefinition(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID).defaultDifficultyId,
|
||||
bossDifficulty,
|
||||
partySize: 5,
|
||||
fillWithAi: true,
|
||||
includeBetaSpecs: false,
|
||||
});
|
||||
};
|
||||
|
||||
const install = async () => {
|
||||
setDownloading(true); setError("");
|
||||
try {
|
||||
await installContentPack(dungeonContentPackId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID));
|
||||
if (!dungeonContentIsUsable(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)) {
|
||||
throw new Error("The Stockades content pack could not be verified.");
|
||||
}
|
||||
} catch (caught) {
|
||||
setError(caught instanceof Error ? caught.message : "The content download failed.");
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (readinessOpen) return <CompanionReadinessScreen onClose={() => setReadinessOpen(false)} />;
|
||||
|
||||
return (
|
||||
<FrontSurface className="randomized-bite-sized-screen" ariaLabel="Randomized Bite-Sized dungeon">
|
||||
<header className="randomized-bite-sized-screen__header">
|
||||
<BrandMark compact />
|
||||
<div><span>Main menu / Randomized Bite-Sized</span><strong>Cellblock Shuffle</strong></div>
|
||||
<button className="front-button" type="button" onClick={onClose}>Main menu</button>
|
||||
</header>
|
||||
<main className="randomized-bite-sized-screen__layout">
|
||||
<section className="randomized-bite-sized-screen__hero">
|
||||
<p className="front-kicker">A different cellblock every run</p>
|
||||
<h1>Randomized Bite-Sized</h1>
|
||||
<p>Fight through the short Stockades route with every ordinary enemy slot rerolled, then face one randomly selected warden.</p>
|
||||
<div className="randomized-bite-sized-screen__odds">
|
||||
<div><strong>{mobCount}</strong><span>mob types in the shuffle</span></div>
|
||||
<div><strong>{bossCount}</strong><span>possible final bosses</span></div>
|
||||
<div><strong>1</strong><span>boss per run</span></div>
|
||||
</div>
|
||||
<p className="front-notice">The random lineup is locked to the run seed, so online party members and resumed sessions see the same enemies.</p>
|
||||
<OnlineGroupPanel forceAiFill />
|
||||
<CompanionPoolControls onOpenReadiness={() => setReadinessOpen(true)} readyOnly />
|
||||
</section>
|
||||
<section className="randomized-bite-sized-screen__launch">
|
||||
<p className="front-kicker">Run setup</p>
|
||||
<h2>{DUNGEON?.title ?? "Cellblock Shuffle"}</h2>
|
||||
<DungeonDifficultySlider value={bossDifficulty} onChange={setBossDifficulty} selectedId="" select={() => undefined} />
|
||||
{!installed ? (
|
||||
<button className="front-button front-button--primary" type="button" disabled={downloading} onClick={() => void install()}>{downloading ? "Installing…" : "Install Stockades 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} onClick={() => void launch(role)}>{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>}
|
||||
{(error || notice) && <p className="front-notice" role="status">{error || notice}</p>}
|
||||
</section>
|
||||
</main>
|
||||
</FrontSurface>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
CREATURE_READINESS_LABELS,
|
||||
CREATURE_READINESS_STATUSES,
|
||||
type CreatureReadinessStatus,
|
||||
useRandomizedCreatureReadiness,
|
||||
} from "../app/randomizedCreatureReadiness";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { STORMWIND_STOCKADE_DUNGEON_ID } from "../game/dungeonContentSource";
|
||||
import {
|
||||
randomizedBiteSizedCreatureCatalog,
|
||||
type RandomizedCreatureKind,
|
||||
} from "../game/dungeons/randomizedBiteSizedStockades";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
|
||||
const CREATURES = randomizedBiteSizedCreatureCatalog(
|
||||
requireDungeonDefinition(STORMWIND_STOCKADE_DUNGEON_ID),
|
||||
);
|
||||
|
||||
type KindFilter = "all" | RandomizedCreatureKind;
|
||||
type StatusFilter = "all" | CreatureReadinessStatus;
|
||||
|
||||
export function RandomizedCreatureReadinessScreen({ onClose }: { readonly onClose: () => void }) {
|
||||
const session = useShellStore((state) => state.session);
|
||||
const reviews = useRandomizedCreatureReadiness((state) => state.reviews);
|
||||
const loading = useRandomizedCreatureReadiness((state) => state.loading);
|
||||
const error = useRandomizedCreatureReadiness((state) => state.error);
|
||||
const refresh = useRandomizedCreatureReadiness((state) => state.refresh);
|
||||
const save = useRandomizedCreatureReadiness((state) => state.save);
|
||||
const [query, setQuery] = useState("");
|
||||
const [kind, setKind] = useState<KindFilter>("all");
|
||||
const [status, setStatus] = useState<StatusFilter>("all");
|
||||
const [selectedKey, setSelectedKey] = useState(CREATURES[0]?.key ?? "");
|
||||
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
const statusOf = (key: string): CreatureReadinessStatus => reviews[key]?.status ?? "needs-testing";
|
||||
const filtered = useMemo(() => CREATURES.filter((creature) => (
|
||||
(kind === "all" || creature.kind === kind)
|
||||
&& (status === "all" || statusOf(creature.key) === status)
|
||||
&& (!query.trim() || creature.name.toLowerCase().includes(query.trim().toLowerCase())
|
||||
|| String(creature.serverEntry ?? "").includes(query.trim()))
|
||||
)), [kind, query, reviews, status]);
|
||||
const selected = CREATURES.find((creature) => creature.key === selectedKey) ?? filtered[0] ?? null;
|
||||
const counts = Object.fromEntries(CREATURE_READINESS_STATUSES.map((candidate) => [
|
||||
candidate,
|
||||
CREATURES.filter((creature) => statusOf(creature.key) === candidate).length,
|
||||
])) as Record<CreatureReadinessStatus, number>;
|
||||
|
||||
return (
|
||||
<FrontSurface className="randomized-creature-readiness" ariaLabel="Randomized creature readiness">
|
||||
<header className="randomized-creature-readiness__header">
|
||||
<BrandMark compact />
|
||||
<div><p className="eyebrow">Game Master tools</p><h1>Randomized creature readiness</h1></div>
|
||||
<div>
|
||||
<button className="front-button" type="button" disabled={loading} onClick={() => void refresh()}>Refresh</button>
|
||||
<button className="front-button" type="button" onClick={onClose}>Back</button>
|
||||
</div>
|
||||
</header>
|
||||
<section className="randomized-creature-readiness__summary" aria-label="Readiness counts">
|
||||
{CREATURE_READINESS_STATUSES.map((candidate) => (
|
||||
<div key={candidate}><strong>{counts[candidate]}</strong><span>{CREATURE_READINESS_LABELS[candidate]}</span></div>
|
||||
))}
|
||||
</section>
|
||||
<main className="randomized-creature-readiness__layout">
|
||||
<section className="randomized-creature-readiness__catalog">
|
||||
<div className="randomized-creature-readiness__filters">
|
||||
<label><span>Search</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Name or server entry" /></label>
|
||||
<label><span>Kind</span><select value={kind} onChange={(event) => setKind(event.target.value as KindFilter)}>
|
||||
<option value="all">All</option><option value="mob">Mobs</option><option value="boss">Bosses</option>
|
||||
</select></label>
|
||||
<label><span>Status</span><select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
|
||||
<option value="all">All</option>{CREATURE_READINESS_STATUSES.map((candidate) => <option key={candidate} value={candidate}>{CREATURE_READINESS_LABELS[candidate]}</option>)}
|
||||
</select></label>
|
||||
</div>
|
||||
<div className="randomized-creature-readiness__list">
|
||||
{filtered.map((creature) => (
|
||||
<button key={creature.key} className={creature.key === selected?.key ? "is-active" : ""} type="button" onClick={() => setSelectedKey(creature.key)}>
|
||||
<span><strong>{creature.name}</strong><small>{creature.kind} · entry {creature.serverEntry ?? "unknown"}</small></span>
|
||||
<em className={`creature-readiness creature-readiness--${statusOf(creature.key)}`}>{CREATURE_READINESS_LABELS[statusOf(creature.key)]}</em>
|
||||
</button>
|
||||
))}
|
||||
{!filtered.length && <p className="front-notice">No creatures match these filters.</p>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="randomized-creature-readiness__details">
|
||||
{selected ? <>
|
||||
<p className="front-kicker">{selected.kind === "boss" ? "Boss candidate" : "Mob candidate"}</p>
|
||||
<h2>{selected.name}</h2>
|
||||
<dl><div><dt>Source</dt><dd>{selected.sourceDungeonTitle}</dd></div><div><dt>Entity</dt><dd>{selected.entityId}</dd></div><div><dt>Server entry</dt><dd>{selected.serverEntry ?? "Unknown"}</dd></div></dl>
|
||||
<p>Current status: <strong>{CREATURE_READINESS_LABELS[statusOf(selected.key)]}</strong></p>
|
||||
<div className="randomized-creature-readiness__status-actions">
|
||||
{CREATURE_READINESS_STATUSES.map((candidate) => (
|
||||
<button className={`front-button ${candidate === statusOf(selected.key) ? "front-button--primary" : ""}`} key={candidate} type="button" disabled={loading || !session} onClick={() => session && void save(session, selected, candidate)}>{CREATURE_READINESS_LABELS[candidate]}</button>
|
||||
))}
|
||||
</div>
|
||||
{reviews[selected.key] && <small>Last reviewed by {reviews[selected.key].reviewedBy} on {new Date(reviews[selected.key].reviewedAt).toLocaleString()}.</small>}
|
||||
</> : <p className="front-notice">Select a creature to review it.</p>}
|
||||
{error && <p className="front-notice" role="alert">{error}</p>}
|
||||
</section>
|
||||
</main>
|
||||
</FrontSurface>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user