156 lines
8.5 KiB
TypeScript
156 lines
8.5 KiB
TypeScript
import { useEffect, 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,
|
|
dungeonContentPackId,
|
|
} from "../game/dungeonContentSource";
|
|
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";
|
|
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
|
import { CompanionPoolControls } from "./CompanionPoolControls";
|
|
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
|
|
import { ensureRandomizedBiteSizedRunContent } from "../app/randomizedBiteSizedContent";
|
|
|
|
installDungeonShellRuntime();
|
|
|
|
const DUNGEON = dungeonById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
|
|
const CANDIDATES = RANDOMIZED_BITE_SIZED_CREATURE_CATALOG;
|
|
|
|
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 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
|
|
? 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 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(selectedDungeon.id, role, {
|
|
difficultyId: requireDungeonDefinition(selectedDungeon.id).defaultDifficultyId,
|
|
bossDifficulty,
|
|
freshRun: true,
|
|
runId,
|
|
});
|
|
} catch (caught) {
|
|
setError(caught instanceof Error ? caught.message : "The randomized creature download failed.");
|
|
} finally {
|
|
setDownloading(false);
|
|
}
|
|
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: selectedDungeon.id,
|
|
difficultyId: requireDungeonDefinition(selectedDungeon.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 lineup every run</p>
|
|
<h1>Randomized Bite-Sized</h1>
|
|
<p>The mode picks an eligible short layout—currently Cellblock Rush—deals a different cross-dungeon creature into every enemy slot, then rolls the boss separately.</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>{queueEnabled ? DUNGEON?.title ?? "Cellblock Shuffle" : "No enabled location"}</h2>
|
|
<DungeonDifficultySlider value={bossDifficulty} onChange={setBossDifficulty} selectedId="" select={() => undefined} />
|
|
{!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 || !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>
|
|
</FrontSurface>
|
|
);
|
|
}
|