Update Healer Man server
This commit is contained in:
1 parent
97ee0c13f9
commit
f864b5aea7
20 files changed
+468
-211
No files matched your search
@@ -3712,3 +3712,8 @@ Vite's existing large-chunk advisory remains informational.
|
||||
card remains fully visible, no character-preview node mounts, and all removed
|
||||
introductory copy is absent. Nineteen focused tests and the production build
|
||||
passed.
|
||||
- The destinations are now split into a dedicated Adventures card for Dungeons
|
||||
and Randomized Bite-Sized plus a secondary Character & Collection card. Boss
|
||||
testing, companion readiness, reports, and the authorized GM/server tools now
|
||||
live behind one controller-navigable GM Menu dialog; unauthorized accounts
|
||||
see GM Tools and Balance Lab as locked rather than losing the menu structure.
|
||||
@@ -17,7 +17,7 @@ test("ships one lossless object for identical assets and verifies original bytes
|
||||
const original = Buffer.from("original geometry, textures, and animation\n".repeat(4096));
|
||||
await writeFile(sourcePath, original);
|
||||
const common = { sourcePath, bytes: original.length, sha256: createHash("sha256").update(original).digest("hex"), extension: ".glb" };
|
||||
const files = ["first", "second"].map((id) => ({ ...common, logicalPath: `assets/creatures/${id}/model.glb`, packId: `dungeon:${id}` }));
|
||||
const files = ["first", "second"].map((id) => ({ ...common, logicalPath: `assets/creatures/${id}/model.glb`, packId: `creatures:${id}` }));
|
||||
const result = await prepareContentObjects(files, { directory: path.join(directory, "objects"), baseUrl: "/content/bundled" });
|
||||
assert.equal(result.uniqueObjects, 1);
|
||||
assert.equal(result.uniqueBytes, original.length);
|
||||
|
||||
@@ -78,6 +78,7 @@ function packLabel(packId) {
|
||||
const [category, id = category] = packId.split(":", 2);
|
||||
const name = id.split("-").map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`).join(" ");
|
||||
if (category === "dungeon") return name;
|
||||
if (category === "creatures") return `${name} Creatures`;
|
||||
if (packId === "characters:wow335a-v1") return "Extra Wrath Character Models & Skins";
|
||||
if (packId === "characters:ascension-v1") return "Ascension Character Models & Skins";
|
||||
if (packId === "characters:runewaker-v1") return "RuneWaker Character Models & Skins";
|
||||
@@ -107,12 +108,13 @@ export function contentPackForPath(logicalPath) {
|
||||
return basicWrathHuman ? "core:character-basic" : `characters:${segments[2]}`;
|
||||
}
|
||||
if (segments[1] === "creatures" && segments[2]) {
|
||||
return segments[2] === "shared" ? "shared:creatures" : `dungeon:${segments[2]}`;
|
||||
return segments[2] === "shared" ? "shared:creatures" : `creatures:${segments[2]}`;
|
||||
}
|
||||
if (segments[1] === "game" && segments[2] === "creatures" && segments[3]) {
|
||||
return segments[3] === "shared" ? "shared:creatures" : `dungeon:${segments[3]}`;
|
||||
return segments[3] === "shared" ? "shared:creatures" : `creatures:${segments[3]}`;
|
||||
}
|
||||
if (segments[1] === "game" && segments[2] === "dungeons" && segments[3]) {
|
||||
if (segments[4] === "creatures") return `creatures:${segments[3]}`;
|
||||
return `dungeon:${segments[3]}`;
|
||||
}
|
||||
if (segments[1] === "game" && segments[2] === "arenas") return "core:arenas";
|
||||
@@ -203,44 +205,58 @@ function assetPackId(url) {
|
||||
|
||||
/**
|
||||
* Builds the logical install topology for the 54-dungeon WoW campaign.
|
||||
* Environment files intentionally remain in their independently downloadable
|
||||
* Manastorm packs; a dungeon pack owns its creatures and pulls in exactly the
|
||||
* environment/shared-creature packs referenced by the generated definition.
|
||||
* Environment and creature files remain independently downloadable. A dungeon
|
||||
* pack pulls in exactly the environment and creature packs referenced by its
|
||||
* generated definition, while randomized modes can install creatures alone.
|
||||
*/
|
||||
export function campaignDungeonPackDefinitions(definitions) {
|
||||
const packs = new Map();
|
||||
for (const definition of definitions) {
|
||||
const dungeonPackId = `dungeon:${definition.id}`;
|
||||
if (definition.id === "wailing-caverns") {
|
||||
const referencedCreaturePacks = new Set([
|
||||
"creatures:wailing-caverns",
|
||||
...Object.values(definition.entities ?? {})
|
||||
.map((entity) => entity.visual?.model?.url)
|
||||
.filter(Boolean)
|
||||
.map(assetPackId),
|
||||
]);
|
||||
const unexpectedCreaturePacks = [...referencedCreaturePacks]
|
||||
.filter((id) => id !== "creatures:wailing-caverns" && id !== "shared:creatures");
|
||||
if (unexpectedCreaturePacks.length) {
|
||||
throw new Error(`${definition.id} references unexpected creature packs: ${unexpectedCreaturePacks.join(", ")}`);
|
||||
}
|
||||
packs.set(dungeonPackId, {
|
||||
id: dungeonPackId,
|
||||
label: definition.title ?? packLabel(dungeonPackId),
|
||||
bundledOnAndroid: true,
|
||||
dependencies: [],
|
||||
dependencies: [...referencedCreaturePacks],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const environmentUrls = [
|
||||
...(definition.assets?.visual ?? []).map((asset) => asset.url),
|
||||
...(definition.assets?.collision ?? []).map((asset) => asset.url),
|
||||
definition.assets?.navigation?.url,
|
||||
].filter(Boolean);
|
||||
const resolvedEnvironmentPacks = environmentUrls.map(assetPackId);
|
||||
if (!resolvedEnvironmentPacks.length || resolvedEnvironmentPacks.some((id) => !id?.startsWith("manastorm:"))) {
|
||||
throw new Error(`${definition.id} has an environment asset outside a Manastorm content pack.`);
|
||||
if (!resolvedEnvironmentPacks.length
|
||||
|| resolvedEnvironmentPacks.some((id) => !id?.startsWith("manastorm:"))) {
|
||||
throw new Error(`${definition.id} has an environment asset outside its expected content pack.`);
|
||||
}
|
||||
const environmentPackIds = new Set(resolvedEnvironmentPacks);
|
||||
if (environmentPackIds.size !== 1) {
|
||||
throw new Error(`${definition.id} must resolve to exactly one Manastorm environment pack.`);
|
||||
}
|
||||
const [environmentPackId] = environmentPackIds;
|
||||
packs.set(environmentPackId, {
|
||||
id: environmentPackId,
|
||||
label: packLabel(environmentPackId),
|
||||
bundledOnAndroid: false,
|
||||
dependencies: [],
|
||||
});
|
||||
if (environmentPackId !== dungeonPackId) {
|
||||
packs.set(environmentPackId, {
|
||||
id: environmentPackId,
|
||||
label: packLabel(environmentPackId),
|
||||
bundledOnAndroid: false,
|
||||
dependencies: [],
|
||||
});
|
||||
}
|
||||
|
||||
const creatureUrls = Object.values(definition.entities ?? {})
|
||||
.map((entity) => entity.visual?.model?.url)
|
||||
@@ -249,16 +265,21 @@ export function campaignDungeonPackDefinitions(definitions) {
|
||||
if (resolvedCreaturePacks.some((id) => !id)) {
|
||||
throw new Error(`${definition.id} has a creature asset outside a content pack.`);
|
||||
}
|
||||
const referencedCreaturePacks = new Set(resolvedCreaturePacks.filter((id) => id !== dungeonPackId));
|
||||
const unexpectedCreaturePacks = [...referencedCreaturePacks].filter((id) => id !== "shared:creatures");
|
||||
const referencedCreaturePacks = new Set(resolvedCreaturePacks);
|
||||
const expectedCreaturePackId = `creatures:${definition.id}`;
|
||||
const unexpectedCreaturePacks = [...referencedCreaturePacks]
|
||||
.filter((id) => id !== expectedCreaturePackId && id !== "shared:creatures");
|
||||
if (unexpectedCreaturePacks.length) {
|
||||
throw new Error(`${definition.id} references unexpected creature packs: ${unexpectedCreaturePacks.join(", ")}`);
|
||||
}
|
||||
packs.set(dungeonPackId, {
|
||||
id: dungeonPackId,
|
||||
label: definition.title ?? packLabel(dungeonPackId),
|
||||
bundledOnAndroid: false,
|
||||
dependencies: [environmentPackId, ...referencedCreaturePacks],
|
||||
bundledOnAndroid: definition.id === "wailing-caverns",
|
||||
dependencies: [
|
||||
...(environmentPackId === dungeonPackId ? [] : [environmentPackId]),
|
||||
...referencedCreaturePacks,
|
||||
],
|
||||
});
|
||||
}
|
||||
return [...packs.values()].sort((left, right) => left.id.localeCompare(right.id));
|
||||
@@ -350,6 +371,15 @@ export function createContentManifest(files, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// RuneWaker dungeon definitions are generated TypeScript rather than part of
|
||||
// the WoW JSON catalog. Their matching creature dependency is recoverable
|
||||
// from the stable pack namespace without importing runtime code here.
|
||||
for (const creaturePack of [...packs.values()].filter((pack) => pack.id.startsWith("creatures:"))) {
|
||||
const dungeonPack = packs.get(`dungeon:${creaturePack.id.slice("creatures:".length)}`);
|
||||
if (!dungeonPack || dungeonPack.dependencies.includes(creaturePack.id)) continue;
|
||||
dungeonPack.dependencies = [...dungeonPack.dependencies, creaturePack.id];
|
||||
}
|
||||
|
||||
const sortedPacks = [...packs.values()].sort((left, right) => left.id.localeCompare(right.id));
|
||||
for (const pack of sortedPacks) {
|
||||
const objects = new Map(files.filter((file) => file.packId === pack.id)
|
||||
|
||||
@@ -26,7 +26,9 @@ test("gives randomized bite-sized its base location dependency closure", () => {
|
||||
|
||||
test("assigns stable player-facing packs from logical asset paths", () => {
|
||||
assert.equal(contentPackForPath("assets/game/dungeons/wailing-caverns/visual.glb"), "dungeon:wailing-caverns");
|
||||
assert.equal(contentPackForPath("assets/creatures/wailing-caverns/mutanus.glb"), "dungeon:wailing-caverns");
|
||||
assert.equal(contentPackForPath("assets/creatures/wailing-caverns/mutanus.glb"), "creatures:wailing-caverns");
|
||||
assert.equal(contentPackForPath("assets/game/creatures/deadmines/mine-rat.glb"), "creatures:deadmines");
|
||||
assert.equal(contentPackForPath("assets/game/dungeons/deadmines/creatures/mr-smite.glb"), "creatures:deadmines");
|
||||
assert.equal(contentPackForPath("assets/characters/wow335a-v1/manifest.json"), "core:character-basic");
|
||||
assert.equal(contentPackForPath("assets/characters/wow335a-v1/human/male/rig.glb"), "core:character-basic");
|
||||
assert.equal(contentPackForPath("assets/characters/wow335a-v1/textures/character/human/male/humanmaleskin00_00.webp"), "core:character-basic");
|
||||
@@ -44,6 +46,27 @@ test("assigns stable player-facing packs from logical asset paths", () => {
|
||||
assert.equal(contentPackForPath("basis/basis_transcoder.wasm"), "core:runtime");
|
||||
});
|
||||
|
||||
test("keeps RuneWaker environments and creatures in independently installable packs", () => {
|
||||
const files = [
|
||||
"assets/game/dungeons/forsaken-abbey/visual.glb",
|
||||
"assets/creatures/forsaken-abbey/ancalon.glb",
|
||||
].map((logicalPath, index) => ({
|
||||
logicalPath,
|
||||
sourcePath: logicalPath,
|
||||
sha256: String(index + 1).repeat(64),
|
||||
bytes: 100,
|
||||
extension: ".glb",
|
||||
packId: contentPackForPath(logicalPath),
|
||||
}));
|
||||
const manifest = createContentManifest(files, { version: "runewaker-creature-split" });
|
||||
const packs = new Map(manifest.packs.map((pack) => [pack.id, pack]));
|
||||
|
||||
assert.deepEqual(packs.get("dungeon:forsaken-abbey")?.dependencies, ["creatures:forsaken-abbey"]);
|
||||
assert.equal(packs.get("dungeon:forsaken-abbey")?.fileCount, 1);
|
||||
assert.equal(packs.get("creatures:forsaken-abbey")?.fileCount, 1);
|
||||
assert.equal(manifest.files["/assets/creatures/forsaken-abbey/ancalon.glb"].packId, "creatures:forsaken-abbey");
|
||||
});
|
||||
|
||||
test("gives every WoW campaign dungeon an exact install dependency closure", async () => {
|
||||
const campaign = JSON.parse(await readFile(path.join(
|
||||
PROJECT_ROOT,
|
||||
@@ -113,6 +136,7 @@ test("gives every WoW campaign dungeon an exact install dependency closure", asy
|
||||
packId: contentPackForPath(logicalPath),
|
||||
});
|
||||
addFile("assets/game/dungeons/wailing-caverns/visual.glb");
|
||||
for (const creature of wailingCreatureManifest.creatures) addFile(creature.url.slice(1));
|
||||
for (const dungeon of campaign.definitions) {
|
||||
for (const asset of [
|
||||
...dungeon.assets.visual,
|
||||
@@ -134,20 +158,27 @@ test("gives every WoW campaign dungeon an exact install dependency closure", asy
|
||||
const pack = packs.get(`dungeon:${dungeon.id}`);
|
||||
assert.ok(pack, dungeon.id);
|
||||
if (dungeon.id === "wailing-caverns") {
|
||||
const creaturePacks = [...new Set([
|
||||
"creatures:wailing-caverns",
|
||||
...Object.values(dungeon.entities).flatMap((entity) => (
|
||||
entity.visual?.model?.url ? [contentPackForPath(entity.visual.model.url.slice(1))] : []
|
||||
)),
|
||||
])];
|
||||
assert.equal(pack.bundledOnAndroid, true);
|
||||
assert.deepEqual(pack.dependencies, []);
|
||||
assert.deepEqual(pack.dependencies, creaturePacks);
|
||||
continue;
|
||||
}
|
||||
const environmentPack = contentPackForPath(dungeon.assets.visual[0].url.slice(1));
|
||||
const needsSharedCreatures = Object.values(dungeon.entities).some((entity) => (
|
||||
entity.visual?.model?.url?.startsWith("/assets/game/creatures/shared/")
|
||||
));
|
||||
const creaturePacks = [...new Set(Object.values(dungeon.entities).flatMap((entity) => (
|
||||
entity.visual?.model?.url ? [contentPackForPath(entity.visual.model.url.slice(1))] : []
|
||||
)))];
|
||||
assert.deepEqual(
|
||||
pack.dependencies,
|
||||
[environmentPack, ...(needsSharedCreatures ? ["shared:creatures"] : [])],
|
||||
[...(environmentPack === pack.id ? [] : [environmentPack]), ...creaturePacks],
|
||||
dungeon.id,
|
||||
);
|
||||
assert.deepEqual(packs.get(environmentPack)?.dependencies, [], dungeon.id);
|
||||
assert.equal(pack.bundledOnAndroid, dungeon.id === "wailing-caverns");
|
||||
if (environmentPack !== pack.id) assert.deepEqual(packs.get(environmentPack)?.dependencies, [], dungeon.id);
|
||||
}
|
||||
|
||||
for (const [dungeonId, environmentPack] of [
|
||||
|
||||
@@ -46,8 +46,9 @@ export async function verifyReleaseBundle(root) {
|
||||
fail(`bootstrap packs were ${actualPacks.join(", ")}; expected ${expectedPacks.join(", ")}`);
|
||||
}
|
||||
const hiddenPack = actualPacks.find((packId) => (
|
||||
contentPolicy.hiddenContentPackIds.includes(packId)
|
||||
|| contentPolicy.hiddenContentPackPrefixes.some((prefix) => packId.startsWith(prefix))
|
||||
!contentPolicy.bootstrapPackIds.includes(packId)
|
||||
&& (contentPolicy.hiddenContentPackIds.includes(packId)
|
||||
|| contentPolicy.hiddenContentPackPrefixes.some((prefix) => packId.startsWith(prefix)))
|
||||
));
|
||||
if (hiddenPack) fail(`bootstrap contains hidden pack ${hiddenPack}`);
|
||||
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
"core:equipment",
|
||||
"core:character-basic",
|
||||
"core:runtime",
|
||||
"dungeon:wailing-caverns"
|
||||
"dungeon:wailing-caverns",
|
||||
"creatures:wailing-caverns"
|
||||
],
|
||||
"hiddenContentPackIds": [
|
||||
"core:arenas"
|
||||
],
|
||||
"hiddenContentPackPrefixes": [
|
||||
"manastorm:",
|
||||
"activity:"
|
||||
"activity:",
|
||||
"creatures:"
|
||||
],
|
||||
"maxBootstrapStoredBytes": 83886080
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BITE_SIZED_STOCKADES_DUNGEON_ID } from "../game/dungeonContentSource";
|
||||
import {
|
||||
BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
} from "../game/dungeonContentSource";
|
||||
import type { DungeonRunProgress } from "../game/dungeonRunTypes";
|
||||
import { canStartAnotherBiteSizedDungeon, startAnotherBiteSizedDungeon } from "./biteSizedDungeonRuntime";
|
||||
import { useOnlineGroupStore } from "./onlineGroupStore";
|
||||
@@ -52,6 +55,36 @@ describe("bite-sized replay", () => {
|
||||
difficultyId: "normal",
|
||||
bossDifficulty: 1,
|
||||
freshRun: true,
|
||||
runId: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("replays randomized mode with a new seed and prepares that seed's creature assets", async () => {
|
||||
const enterDungeon = vi.fn(() => true);
|
||||
const saveCheckpoint = vi.fn();
|
||||
const prepareRandomizedContent = vi.fn(async () => undefined);
|
||||
useShellStore.setState({
|
||||
session: { kind: "offline", ownerId: "local-owner", displayName: "Tester" },
|
||||
activeDungeonRole: "healer",
|
||||
enterDungeon,
|
||||
saveActiveDungeonRunCheckpoint: saveCheckpoint,
|
||||
});
|
||||
const randomizedProgress = { ...progress, dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID };
|
||||
|
||||
await expect(startAnotherBiteSizedDungeon(randomizedProgress, {
|
||||
random: () => 0,
|
||||
createRunId: () => "next-randomized-seed",
|
||||
contentIsUsable: () => true,
|
||||
prepareRandomizedContent,
|
||||
})).resolves.toBe(true);
|
||||
|
||||
expect(prepareRandomizedContent).toHaveBeenCalledWith("next-randomized-seed");
|
||||
expect(saveCheckpoint).toHaveBeenCalledWith(null);
|
||||
expect(enterDungeon).toHaveBeenCalledWith(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, "healer", {
|
||||
difficultyId: "normal",
|
||||
bossDifficulty: 1,
|
||||
freshRun: true,
|
||||
runId: "next-randomized-seed",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,4 +124,31 @@ describe("bite-sized replay", () => {
|
||||
useShellStore.setState({ session: { kind: "account", ownerId: "follower", displayName: "Follower", accessToken: "test-token" } });
|
||||
expect(canStartAnotherBiteSizedDungeon()).toBe(false);
|
||||
});
|
||||
|
||||
it("publishes another randomized activity instead of crossing an online group into regular Bite-Sized", async () => {
|
||||
const startActivity = vi.fn(async () => true);
|
||||
useShellStore.setState({
|
||||
session: { kind: "account", ownerId: "leader", displayName: "Leader", accessToken: "test-token" },
|
||||
saveActiveDungeonRunCheckpoint: vi.fn(),
|
||||
});
|
||||
useOnlineGroupStore.setState({
|
||||
startActivity,
|
||||
snapshot: { invitations: [], group: { id: "group", leaderAccountId: "leader", members: [], activity: null,
|
||||
activityRevision: 2, createdAt: 1, updatedAt: 2 } },
|
||||
});
|
||||
|
||||
await expect(startAnotherBiteSizedDungeon(
|
||||
{ ...progress, dungeonId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID },
|
||||
{ random: () => 0, createRunId: () => "unused-local-seed", contentIsUsable: () => true,
|
||||
prepareRandomizedContent: vi.fn() },
|
||||
)).resolves.toBe(true);
|
||||
|
||||
expect(startActivity).toHaveBeenCalledWith(expect.objectContaining({ ownerId: "leader" }), {
|
||||
type: "dungeon",
|
||||
contentId: RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
difficultyId: "normal",
|
||||
bossDifficulty: 1,
|
||||
partySize: 5,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { dungeonContentIsUsable } from "../content/contentManager";
|
||||
import { randomDungeonByCategory } from "../game/dungeonCatalog";
|
||||
import { randomDungeonByCategory, randomRandomizedBiteSizedDungeon } from "../game/dungeonCatalog";
|
||||
import { isRandomizedBiteSizedDungeonId } from "../game/dungeonContentSource";
|
||||
import { dungeonDefinitionById } from "../game/dungeonRegistry";
|
||||
import type { DungeonRunProgress } from "../game/dungeonRunTypes";
|
||||
import { useOnlineGroupStore } from "./onlineGroupStore";
|
||||
import { useOnlineSessionStore } from "./onlineSessionStore";
|
||||
import { ensureRandomizedBiteSizedRunContent } from "./randomizedBiteSizedContent";
|
||||
import { useShellStore } from "./shellStore";
|
||||
|
||||
export function canStartAnotherBiteSizedDungeon(): boolean {
|
||||
@@ -14,8 +16,25 @@ export function canStartAnotherBiteSizedDungeon(): boolean {
|
||||
return !online || online.authority;
|
||||
}
|
||||
|
||||
interface BiteSizedReplayDependencies {
|
||||
readonly random: () => number;
|
||||
readonly createRunId: () => string;
|
||||
readonly contentIsUsable: (dungeonId: string) => boolean;
|
||||
readonly prepareRandomizedContent: (seed: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_REPLAY_DEPENDENCIES: BiteSizedReplayDependencies = {
|
||||
random: Math.random,
|
||||
createRunId: () => globalThis.crypto.randomUUID(),
|
||||
contentIsUsable: dungeonContentIsUsable,
|
||||
prepareRandomizedContent: ensureRandomizedBiteSizedRunContent,
|
||||
};
|
||||
|
||||
/** Starts a fresh random run locally, or publishes one shared activity for the whole online party. */
|
||||
export async function startAnotherBiteSizedDungeon(progress: DungeonRunProgress): Promise<boolean> {
|
||||
export async function startAnotherBiteSizedDungeon(
|
||||
progress: DungeonRunProgress,
|
||||
dependencies: BiteSizedReplayDependencies = DEFAULT_REPLAY_DEPENDENCIES,
|
||||
): Promise<boolean> {
|
||||
const shell = useShellStore.getState();
|
||||
const groupState = useOnlineGroupStore.getState();
|
||||
const group = groupState.snapshot.group;
|
||||
@@ -25,14 +44,16 @@ export async function startAnotherBiteSizedDungeon(progress: DungeonRunProgress)
|
||||
return false;
|
||||
}
|
||||
|
||||
const next = randomDungeonByCategory(
|
||||
"bite-sized",
|
||||
progress.dungeonId,
|
||||
Math.random,
|
||||
(dungeon) => dungeonContentIsUsable(dungeon.id),
|
||||
);
|
||||
const randomized = isRandomizedBiteSizedDungeonId(progress.dungeonId);
|
||||
const next = randomized
|
||||
? randomRandomizedBiteSizedDungeon(progress.dungeonId, dependencies.random,
|
||||
(dungeon) => dependencies.contentIsUsable(dungeon.id))
|
||||
: randomDungeonByCategory("bite-sized", progress.dungeonId, dependencies.random,
|
||||
(dungeon) => dependencies.contentIsUsable(dungeon.id));
|
||||
if (!next) {
|
||||
shell.setNotice("No installed Bite-Sized dungeon is ready to enter.");
|
||||
shell.setNotice(randomized
|
||||
? "No Randomized Bite-Sized location is ready to enter."
|
||||
: "No installed Bite-Sized dungeon is ready to enter.");
|
||||
return false;
|
||||
}
|
||||
const definition = dungeonDefinitionById(next.id);
|
||||
@@ -65,11 +86,21 @@ export async function startAnotherBiteSizedDungeon(progress: DungeonRunProgress)
|
||||
shell.setNotice("Reconnect to the party before starting the next shared dungeon.");
|
||||
return false;
|
||||
}
|
||||
const runId = dependencies.createRunId();
|
||||
if (randomized) {
|
||||
try {
|
||||
await dependencies.prepareRandomizedContent(runId);
|
||||
} catch (caught) {
|
||||
shell.setNotice(caught instanceof Error ? caught.message : "The randomized creature download failed.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
shell.saveActiveDungeonRunCheckpoint(null);
|
||||
const entered = shell.enterDungeon(next.id, shell.activeDungeonRole, {
|
||||
difficultyId,
|
||||
bossDifficulty: progress.bossDifficulty,
|
||||
freshRun: true,
|
||||
runId,
|
||||
});
|
||||
return entered;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
dungeonById,
|
||||
dungeonsByCategory,
|
||||
randomDungeonByCategory,
|
||||
randomizedBiteSizedDungeons,
|
||||
randomRandomizedBiteSizedDungeon,
|
||||
} from "./dungeonCatalog";
|
||||
import { DUNGEON_MANIFEST } from "./dungeonManifest";
|
||||
import { RUNEWAKER_DUNGEON_DEFINITIONS } from "./generated/runewakerDungeonDefinitions.generated";
|
||||
@@ -59,6 +61,13 @@ describe("dungeon catalog", () => {
|
||||
.not.toBe("stormwind-stockade");
|
||||
});
|
||||
|
||||
it("keeps randomized replay in the separate randomized Bite-Sized location pool", () => {
|
||||
expect(randomizedBiteSizedDungeons().map((dungeon) => dungeon.id))
|
||||
.toEqual(["randomized-bite-sized-stormwind-stockade"]);
|
||||
expect(randomRandomizedBiteSizedDungeon("randomized-bite-sized-stormwind-stockade", () => 0)?.id)
|
||||
.toBe("randomized-bite-sized-stormwind-stockade");
|
||||
});
|
||||
|
||||
it("keeps stable unique ids and resolves only catalogued dungeons", () => {
|
||||
const ids = DUNGEON_CATALOG.map((dungeon) => dungeon.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { contentCategoryById, type ContentCategoryId } from "../app/contentCateg
|
||||
import { compareDungeonReleaseOrder, dungeonReleaseStage, type DungeonReleaseStage } from "./dungeonReleaseStage";
|
||||
import {
|
||||
BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
isRandomizedBiteSizedDungeonId,
|
||||
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
} from "./dungeonContentSource";
|
||||
|
||||
@@ -66,10 +67,32 @@ export function dungeonById(id: string): DungeonCatalogEntry | null {
|
||||
|
||||
export function dungeonsByCategory(category: DungeonCategoryId): readonly DungeonCatalogEntry[] {
|
||||
return DUNGEON_CATALOG.filter((dungeon) => (
|
||||
dungeon.category === category && dungeon.id !== RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID
|
||||
dungeon.category === category && !isRandomizedBiteSizedDungeonId(dungeon.id)
|
||||
));
|
||||
}
|
||||
|
||||
/** The separately launched randomized mode, including every eligible location added later. */
|
||||
export function randomizedBiteSizedDungeons(): readonly DungeonCatalogEntry[] {
|
||||
return DUNGEON_CATALOG.filter((dungeon) => (
|
||||
dungeon.category === "bite-sized" && isRandomizedBiteSizedDungeonId(dungeon.id)
|
||||
));
|
||||
}
|
||||
|
||||
function randomAvailableDungeon(
|
||||
dungeons: readonly DungeonCatalogEntry[],
|
||||
currentDungeonId: string | undefined,
|
||||
random: () => number,
|
||||
eligible: (dungeon: DungeonCatalogEntry) => boolean,
|
||||
): DungeonCatalogEntry | null {
|
||||
const available = dungeons.filter((dungeon) => dungeon.available && eligible(dungeon));
|
||||
const candidates = available.length > 1
|
||||
? available.filter((dungeon) => dungeon.id !== currentDungeonId)
|
||||
: available;
|
||||
if (candidates.length === 0) return null;
|
||||
const roll = Math.max(0, Math.min(0.9999999999999999, random()));
|
||||
return candidates[Math.floor(roll * candidates.length)] ?? candidates[0];
|
||||
}
|
||||
|
||||
/** Selects from enterable category content and avoids an immediate repeat when alternatives exist. */
|
||||
export function randomDungeonByCategory(
|
||||
category: DungeonCategoryId,
|
||||
@@ -77,11 +100,14 @@ export function randomDungeonByCategory(
|
||||
random: () => number = Math.random,
|
||||
eligible: (dungeon: DungeonCatalogEntry) => boolean = () => true,
|
||||
): DungeonCatalogEntry | null {
|
||||
const available = dungeonsByCategory(category).filter((dungeon) => dungeon.available && eligible(dungeon));
|
||||
const candidates = available.length > 1
|
||||
? available.filter((dungeon) => dungeon.id !== currentDungeonId)
|
||||
: available;
|
||||
if (candidates.length === 0) return null;
|
||||
const roll = Math.max(0, Math.min(0.9999999999999999, random()));
|
||||
return candidates[Math.floor(roll * candidates.length)] ?? candidates[0];
|
||||
return randomAvailableDungeon(dungeonsByCategory(category), currentDungeonId, random, eligible);
|
||||
}
|
||||
|
||||
/** Selects the next location without crossing from randomized mode into authored Bite-Sized mode. */
|
||||
export function randomRandomizedBiteSizedDungeon(
|
||||
currentDungeonId?: string,
|
||||
random: () => number = Math.random,
|
||||
eligible: (dungeon: DungeonCatalogEntry) => boolean = () => true,
|
||||
): DungeonCatalogEntry | null {
|
||||
return randomAvailableDungeon(randomizedBiteSizedDungeons(), currentDungeonId, random, eligible);
|
||||
}
|
||||
@@ -3,6 +3,11 @@ export const BITE_SIZED_STOCKADES_DUNGEON_ID = "bite-sized-stormwind-stockade";
|
||||
export const RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID = "randomized-bite-sized-stormwind-stockade";
|
||||
export const RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID = "activity:randomized-bite-sized";
|
||||
|
||||
/** Randomized Bite-Sized locations share this id namespace as the mode expands. */
|
||||
export function isRandomizedBiteSizedDungeonId(dungeonId: string): boolean {
|
||||
return dungeonId.startsWith("randomized-bite-sized-");
|
||||
}
|
||||
|
||||
const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.freeze({
|
||||
[BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
|
||||
[RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
|
||||
|
||||
@@ -12,15 +12,24 @@ export interface RandomizedBiteSizedCreature {
|
||||
readonly entityId: string;
|
||||
readonly name: string;
|
||||
readonly kind: RandomizedCreatureKind;
|
||||
readonly serverEntry: number;
|
||||
/** Source-catalog identity; RuneWaker entries never leak into AzerothCore combat ids. */
|
||||
readonly sourceEntry: number;
|
||||
readonly definition: PopulationEntityDefinition;
|
||||
}
|
||||
|
||||
function creatureSourceEntry(source: DungeonDefinition, entity: PopulationEntityDefinition): number | null {
|
||||
if (source.expansion !== "runewaker") return entity.combat?.serverEntry ?? null;
|
||||
const encodedEntry = /-([1-9][0-9]*)$/.exec(entity.id)?.[1];
|
||||
if (!encodedEntry) return null;
|
||||
const sourceEntry = Number(encodedEntry);
|
||||
return Number.isSafeInteger(sourceEntry) ? sourceEntry : null;
|
||||
}
|
||||
|
||||
function creatureKey(source: DungeonDefinition, entity: PopulationEntityDefinition): string | null {
|
||||
const serverEntry = entity.combat?.serverEntry;
|
||||
if (!serverEntry) return null;
|
||||
const sourceEntry = creatureSourceEntry(source, entity);
|
||||
if (!sourceEntry) return null;
|
||||
const catalog = source.expansion === "runewaker" ? "rom" : "wow";
|
||||
return `${catalog}:${entity.kind}:${serverEntry}`;
|
||||
return `${catalog}:${entity.kind}:${sourceEntry}`;
|
||||
}
|
||||
|
||||
function seedHash(seed: string): number {
|
||||
@@ -64,8 +73,8 @@ export function randomizedBiteSizedCreatureCatalog(
|
||||
const candidates = sources
|
||||
.flatMap((source) => Object.values(source.entities).flatMap((entity) => {
|
||||
const key = creatureKey(source, entity);
|
||||
const serverEntry = entity.combat?.serverEntry;
|
||||
if (!key || !serverEntry || !entity.visual.model?.url) return [];
|
||||
const sourceEntry = creatureSourceEntry(source, entity);
|
||||
if (!key || !sourceEntry || !entity.visual.model?.url) return [];
|
||||
return [{
|
||||
key,
|
||||
sourceDungeonId: source.id,
|
||||
@@ -73,13 +82,13 @@ export function randomizedBiteSizedCreatureCatalog(
|
||||
entityId: entity.id,
|
||||
name: entity.name,
|
||||
kind: entity.kind,
|
||||
serverEntry,
|
||||
sourceEntry,
|
||||
definition: entity,
|
||||
} satisfies RandomizedBiteSizedCreature];
|
||||
}))
|
||||
.sort((left, right) => left.kind.localeCompare(right.kind)
|
||||
|| left.name.localeCompare(right.name)
|
||||
|| left.serverEntry - right.serverEntry
|
||||
|| left.sourceEntry - right.sourceEntry
|
||||
|| left.sourceDungeonId.localeCompare(right.sourceDungeonId));
|
||||
return [...new Map(candidates.map((candidate) => [candidate.key, candidate])).values()];
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@ function create(seed: string) {
|
||||
return createRandomizedBiteSizedStockadesDefinition(source, seed, catalog);
|
||||
}
|
||||
|
||||
function bossEntityServerEntry(definition: ReturnType<typeof create>): number | undefined {
|
||||
function bossEntityId(definition: ReturnType<typeof create>): string | undefined {
|
||||
const boss = definition.bosses[0];
|
||||
const spawn = definition.staticSpawns.find((candidate) => candidate.id === boss.id);
|
||||
return spawn ? definition.entities[spawn.entityId]?.combat?.serverEntry : undefined;
|
||||
return spawn?.entityId;
|
||||
}
|
||||
|
||||
describe("Randomized Bite-Sized Stockades", () => {
|
||||
@@ -50,7 +50,13 @@ describe("Randomized Bite-Sized Stockades", () => {
|
||||
expect(catalog.some((candidate) => candidate.definition.visual.model?.url.startsWith("/assets/game/dungeons/")
|
||||
&& candidate.sourceDungeonId !== STORMWIND_STOCKADE_DUNGEON_ID)).toBe(true);
|
||||
expect(catalog.some((candidate) => candidate.sourceDungeonId === STORMWIND_STOCKADE_DUNGEON_ID)).toBe(true);
|
||||
expect(catalog.some((candidate) => candidate.key.startsWith("wow:"))).toBe(true);
|
||||
expect(catalog.some((candidate) => candidate.key.startsWith("rom:"))).toBe(true);
|
||||
expect(catalog.filter((candidate) => candidate.key.startsWith("rom:")).length).toBeGreaterThan(400);
|
||||
expect(catalog.every((candidate) => /^(wow|rom):(mob|boss):[1-9][0-9]*$/.test(candidate.key))).toBe(true);
|
||||
const romCandidate = catalog.find((candidate) => candidate.key.startsWith("rom:"))!;
|
||||
expect(romCandidate.sourceEntry).toBeGreaterThan(0);
|
||||
expect(romCandidate.definition.combat).not.toHaveProperty("serverEntry");
|
||||
});
|
||||
|
||||
it("replaces every corridor spawn and deals distinct mobs without replacement", () => {
|
||||
@@ -82,21 +88,21 @@ describe("Randomized Bite-Sized Stockades", () => {
|
||||
expect(second.staticSpawns).toEqual(first.staticSpawns);
|
||||
expect(second.bosses).toEqual(first.bosses);
|
||||
|
||||
const bossEntries = new Set(catalog.filter((candidate) => candidate.kind === "boss").map((candidate) => candidate.serverEntry));
|
||||
const observedBossEntries = new Set<number>();
|
||||
const bossEntityIds = new Set(catalog.filter((candidate) => candidate.kind === "boss").map((candidate) => candidate.entityId));
|
||||
const observedBossEntityIds = new Set<string>();
|
||||
const observedTrashLineups = new Set<string>();
|
||||
for (let index = 0; index < 2_000; index += 1) {
|
||||
const definition = create(`roll-${index}`);
|
||||
const entry = bossEntityServerEntry(definition);
|
||||
if (entry) observedBossEntries.add(entry);
|
||||
const entityId = bossEntityId(definition);
|
||||
if (entityId) observedBossEntityIds.add(entityId);
|
||||
const bossId = definition.bosses[0].id;
|
||||
observedTrashLineups.add(definition.staticSpawns
|
||||
.filter((spawn) => spawn.id !== bossId)
|
||||
.map((spawn) => spawn.entityId)
|
||||
.join("|"));
|
||||
}
|
||||
expect([...observedBossEntries].every((entry) => bossEntries.has(entry))).toBe(true);
|
||||
expect(observedBossEntries.size).toBeGreaterThan(100);
|
||||
expect([...observedBossEntityIds].every((entityId) => bossEntityIds.has(entityId))).toBe(true);
|
||||
expect(observedBossEntityIds.size).toBeGreaterThan(100);
|
||||
expect(observedTrashLineups.size).toBeGreaterThan(1_900);
|
||||
|
||||
expect(activateDungeonSession(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "shared-run" })).toBe(true);
|
||||
|
||||
+23
-1
@@ -2271,7 +2271,16 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
|
||||
.main-menu-sidebar > .online-group-panel { margin: 10px 0 0; border-color: rgba(159,217,223,.3); border-radius: 4px; background: linear-gradient(155deg, rgba(14,31,32,.94), rgba(4,15,17,.96)); }
|
||||
.main-menu-sidebar > .online-group-panel > summary { min-height: 46px; }
|
||||
.main-menu-modes { grid-column: 1; grid-row: 2; min-width: 0; min-height: 0; padding: 0 3px 10px 0; overflow: auto; }
|
||||
.main-menu-mode-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 0; }
|
||||
.main-menu-mode-grid { display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 0; }
|
||||
.main-menu-card { padding: 11px; border: 1px solid rgba(210,177,109,.3); border-radius: 5px; background: linear-gradient(155deg, rgba(19,29,32,.94), rgba(5,12,16,.94)); box-shadow: 0 14px 32px rgba(0,0,0,.28); }
|
||||
.main-menu-card > header { display: flex; align-items: center; gap: 8px; margin-bottom: 9px; padding-bottom: 7px; border-bottom: 1px solid rgba(210,177,109,.17); }
|
||||
.main-menu-card > header span { display: grid; width: 22px; height: 22px; place-items: center; border: 1px solid rgba(210,177,109,.42); border-radius: 50%; color: var(--main-menu-gold); font: 10px Georgia, serif; }
|
||||
.main-menu-card > header strong { color: #e8dec7; font: 15px Georgia, serif; font-weight: 400; letter-spacing: .04em; }
|
||||
.main-menu-card__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.main-menu-card--adventures { border-color: rgba(159,217,223,.32); background: linear-gradient(145deg, rgba(22,46,49,.92), rgba(6,18,22,.96)); box-shadow: 0 16px 36px rgba(0,0,0,.32), inset 0 1px 0 rgba(159,217,223,.06); }
|
||||
.main-menu-card--adventures .main-menu-mode { min-height: 96px; border-color: rgba(159,217,223,.42); background: linear-gradient(135deg, rgba(64,116,121,.34), rgba(21,54,58,.5)); }
|
||||
.main-menu-card--adventures .main-menu-mode:hover { background: linear-gradient(135deg, rgba(77,139,145,.44), rgba(25,65,70,.6)); }
|
||||
.main-menu-card--secondary .main-menu-mode { min-height: 70px; }
|
||||
.main-menu-gm-stack { display: grid; gap: 8px; }
|
||||
.main-menu-mode {
|
||||
position: relative;
|
||||
@@ -2294,10 +2303,21 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
|
||||
.main-menu-mode--available { border-color: rgba(200,226,142,.4); background: linear-gradient(135deg, rgba(91,126,70,.32), rgba(25,48,34,.48)); }
|
||||
.main-menu-mode--available > em { color: var(--front-green-bright); }
|
||||
.main-menu-mode--available:hover { background: linear-gradient(135deg, rgba(105,145,80,.42), rgba(30,58,40,.58)); }
|
||||
.main-menu-mode--gm-menu { border-color: rgba(210,177,109,.5); background: linear-gradient(135deg, rgba(127,91,37,.34), rgba(57,40,28,.54)); }
|
||||
.main-menu-mode--gm-menu:hover { background: linear-gradient(135deg, rgba(151,109,45,.44), rgba(72,50,32,.62)); }
|
||||
.main-menu-mode--coming-soon:disabled { opacity: .62; filter: saturate(.4); }
|
||||
.main-menu-mode--coming-soon > b { color: #7d806f; }
|
||||
.main-menu-notice { margin-right: 8px; }
|
||||
.main-menu-screen > .controller-legend { grid-column: 1 / 3; grid-row: 3; position: static; align-self: end; justify-self: end; }
|
||||
.main-menu-gm-modal { width: min(700px, 100%); max-height: min(440px, 100%); padding: 18px; overflow-y: auto; border: 1px solid rgba(210,177,109,.48); border-radius: 6px; background: linear-gradient(155deg, rgba(25,35,38,.98), rgba(5,12,16,.99)); box-shadow: 0 30px 80px #000, inset 3px 0 0 rgba(210,177,109,.62); }
|
||||
.main-menu-gm-modal > header { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-bottom: 12px; padding-bottom: 10px; border-bottom: 1px solid rgba(210,177,109,.22); }
|
||||
.main-menu-gm-modal > header div { display: flex; flex-direction: column; }
|
||||
.main-menu-gm-modal > header span { color: #9fb2b0; font-size: 7px; letter-spacing: .15em; text-transform: uppercase; }
|
||||
.main-menu-gm-modal > header h2 { margin: 2px 0 0; color: #f4eddd; font: 28px Georgia, serif; font-weight: 400; }
|
||||
.main-menu-gm-modal > header button { min-height: 36px; padding: 7px 12px; border: 1px solid rgba(210,177,109,.3); border-radius: 4px; background: rgba(8,17,21,.8); color: #d8d2c3; cursor: pointer; }
|
||||
.main-menu-gm-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.main-menu-gm-grid .main-menu-mode { min-height: 78px; border-color: rgba(210,177,109,.34); background: linear-gradient(135deg, rgba(103,76,39,.28), rgba(29,39,40,.58)); }
|
||||
.main-menu-gm-grid .main-menu-mode:disabled { cursor: not-allowed; opacity: .48; filter: saturate(.35); }
|
||||
|
||||
/* Dungeon browser */
|
||||
.dungeon-select-screen {
|
||||
@@ -2927,6 +2947,8 @@ html[data-display-surface="bottom"] .single-context-toggle { display: none; }
|
||||
.main-menu-header .front-brand, .dungeon-select-header .front-brand, .manastorm-select-header .front-brand { gap: 0; }
|
||||
.main-menu-mode-grid { grid-template-columns: 1fr; }
|
||||
.main-menu-sidebar { max-height: 210px; grid-template-columns: 1fr; }
|
||||
.main-menu-card__grid, .main-menu-gm-grid { grid-template-columns: 1fr; }
|
||||
.main-menu-gm-modal { padding: 13px; }
|
||||
.main-menu-mode { min-height: 58px; }
|
||||
.dungeon-select-screen { grid-template-columns: 1fr; grid-template-rows: 48px minmax(150px, 42%) minmax(0,58%); gap: 0; }
|
||||
.dungeon-select-header { grid-column: 1; grid-row: 1; }
|
||||
|
||||
@@ -103,11 +103,13 @@ describe("browser Downloads actions", () => {
|
||||
...content.packs,
|
||||
{ id: "core:arenas", label: "Rated Arenas", bytes: 1, fileCount: 1, bundledOnAndroid: false, dependencies: [] },
|
||||
{ id: "manastorm:deadmines", label: "Deadmines Manastorm", bytes: 1, fileCount: 1, bundledOnAndroid: false, dependencies: [] },
|
||||
{ id: "creatures:deadmines", label: "Deadmines Creatures", bytes: 1, fileCount: 1, bundledOnAndroid: false, dependencies: [] },
|
||||
];
|
||||
const markup = renderToStaticMarkup(createElement(ContentManagerScreen, { onClose: () => undefined }));
|
||||
|
||||
expect(markup).toContain("Test Characters");
|
||||
expect(markup).not.toContain("Rated Arenas");
|
||||
expect(markup).not.toContain("Deadmines Manastorm");
|
||||
expect(markup).not.toContain("Deadmines Creatures");
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { awaitSharedDungeonRunPlacement, localDungeonRunPlacement, saveLocalDung
|
||||
import { useOnlineSessionStore } from "../app/onlineSessionStore";
|
||||
import { startAnotherBiteSizedDungeon } from "../app/biteSizedDungeonRuntime";
|
||||
import { dungeonById } from "../game/dungeonCatalog";
|
||||
import { isRandomizedBiteSizedDungeonId } from "../game/dungeonContentSource";
|
||||
import { completedDungeonRun, useDungeonRunStore } from "../game/dungeonRunStore";
|
||||
import { formatDungeonDuration, type DungeonRunPlacement, type DungeonRunProgress } from "../game/dungeonRunTypes";
|
||||
import catalog from "../game/generated/dungeonLeaderboardCatalog.generated.json";
|
||||
@@ -35,6 +36,7 @@ export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, o
|
||||
const definition = catalog.dungeons.find((dungeon) => dungeon.id === progress.dungeonId);
|
||||
const difficulty = definition?.difficulties.find((entry) => entry.id === progress.difficultyId);
|
||||
const fullClear = difficulty ? isLeaderboardFullClear(difficulty, progress.killedBossIds) : false;
|
||||
const randomizedBiteSized = isRandomizedBiteSizedDungeonId(progress.dungeonId);
|
||||
const controller = useMenuController([
|
||||
...(onAnother ? [{ id: "run-another", run: onAnother, enabled: !startingAnother }] : []),
|
||||
{ id: "run-loot", run: onLoot }, { id: "run-finish", run: onFinish },
|
||||
@@ -78,7 +80,9 @@ export function DungeonRunResultsDialog({ progress, ranking, onFinish, onLoot, o
|
||||
<footer>
|
||||
{onAnother ? <ControllerButton {...controls} controlId="run-another" className="button button--primary"
|
||||
disabled={startingAnother} onClick={onAnother}>
|
||||
{startingAnother ? "Starting next dungeon…" : "Another random Bite-Sized dungeon"}
|
||||
{startingAnother ? "Starting next dungeon…" : randomizedBiteSized
|
||||
? "Another Randomized Bite-Sized dungeon"
|
||||
: "Another random Bite-Sized dungeon"}
|
||||
</ControllerButton> : null}
|
||||
<ControllerButton {...controls} controlId="run-loot" className="button" onClick={onLoot}>Stay and loot</ControllerButton>
|
||||
<ControllerButton {...controls} controlId="run-finish" className={`button ${onAnother ? "" : "button--primary"}`} onClick={onFinish}>Return to main menu</ControllerButton>
|
||||
|
||||
+127
-140
@@ -106,6 +106,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
readonly onOpenRandomizedBiteSized: () => void;
|
||||
readonly onOpenEditor: (editor: MainMenuEditor) => void;
|
||||
}) {
|
||||
const [gmMenuOpen, setGmMenuOpen] = useState(false);
|
||||
const activeCharacter = useShellStore((state) => state.activeCharacter);
|
||||
const rosterCharacter = useShellStore(selectedCharacter);
|
||||
const openDungeons = useShellStore((state) => state.openDungeons);
|
||||
@@ -120,35 +121,23 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
const isRom = character?.categoryId === "rom";
|
||||
const isGm = sessionCanUseGmTools(session);
|
||||
|
||||
const actions = useMemo<MenuAction[]>(() => [
|
||||
const actions = useMemo<MenuAction[]>(() => gmMenuOpen ? [
|
||||
...(isGm ? [{ id: "gm-menu-gm-tools", run: openGmAdmin, neighbors: { up: "gm-menu-close" } }] : []),
|
||||
{ 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-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" } },
|
||||
] : [
|
||||
{
|
||||
id: "mode-dungeons",
|
||||
run: openDungeons,
|
||||
neighbors: { right: "mode-randomized-bite-sized", down: "back-to-roster" },
|
||||
neighbors: { up: "back-to-roster", right: "mode-randomized-bite-sized" },
|
||||
},
|
||||
{
|
||||
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-randomized-bite-sized", down: "back-to-roster" },
|
||||
}, {
|
||||
id: "mode-arenas",
|
||||
run: openArenas,
|
||||
neighbors: { left: "mode-manastorms", down: "back-to-roster" },
|
||||
}] : []),
|
||||
{
|
||||
id: "mode-downloads",
|
||||
run: onOpenDownloads,
|
||||
neighbors: { left: SHELVED_MODES_ENABLED ? "mode-manastorms" : "mode-dungeons", down: "back-to-roster" },
|
||||
},
|
||||
{
|
||||
id: "back-to-roster",
|
||||
run: returnToCharacters,
|
||||
neighbors: { up: "mode-dungeons", left: "mode-dungeons" },
|
||||
neighbors: { up: "back-to-roster", left: "mode-dungeons" },
|
||||
},
|
||||
...(character ? [
|
||||
{ id: "mode-class-hall", run: openClassHall },
|
||||
@@ -156,23 +145,59 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
{ id: "mode-keybinds", run: () => onOpenEditor("keybinds") },
|
||||
{ id: "mode-bindings", run: () => onOpenEditor("bindings") },
|
||||
] : []),
|
||||
...(isGm ? [
|
||||
{ id: "mode-gm-tools", run: openGmAdmin },
|
||||
{ id: "mode-balance-lab", run: onOpenBalanceLab },
|
||||
] : []),
|
||||
{ id: "mode-reports", run: onOpenReports },
|
||||
{ id: "mode-cards", run: onOpenCards },
|
||||
{ id: "mode-boss-testing", run: onOpenBossTesting },
|
||||
{ id: "mode-companion-readiness", run: onOpenReadiness },
|
||||
], [character, isGm, onOpenBalanceLab, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
{ id: "mode-gm-menu", run: () => setGmMenuOpen(true) },
|
||||
...(SHELVED_MODES_ENABLED ? [
|
||||
{ id: "mode-manastorms", run: openManastorms },
|
||||
{ id: "mode-arenas", run: openArenas },
|
||||
] : []),
|
||||
{ id: "mode-downloads", run: onOpenDownloads },
|
||||
{ id: "back-to-roster", run: returnToCharacters, neighbors: { down: "mode-dungeons" } },
|
||||
], [character, gmMenuOpen, isGm, onOpenBalanceLab, onOpenBossTesting, onOpenCards, onOpenDownloads, onOpenEditor, onOpenRandomizedBiteSized, onOpenReadiness, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
|
||||
const controller = useMenuController(actions, {
|
||||
initialId: "mode-dungeons",
|
||||
onBack: returnToCharacters,
|
||||
columns: 2,
|
||||
initialId: gmMenuOpen ? (isGm ? "gm-menu-gm-tools" : "gm-menu-boss-testing") : "mode-dungeons",
|
||||
onBack: gmMenuOpen ? () => setGmMenuOpen(false) : returnToCharacters,
|
||||
});
|
||||
|
||||
const characterClass = character ? classById(character.classId) : null;
|
||||
const specialization = specializationById(character?.specializationId);
|
||||
const secondaryClass = character?.secondaryClassId ? classById(character.secondaryClassId) : null;
|
||||
const renderGameMode = (mode: (typeof GAME_MODES)[number]) => mode.available ? (
|
||||
<ControllerButton
|
||||
key={mode.id}
|
||||
controlId={`mode-${mode.id}`}
|
||||
selectedId={controller.selectedId}
|
||||
select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available"
|
||||
type="button"
|
||||
onClick={mode.id === "manastorms"
|
||||
? openManastorms
|
||||
: mode.id === "arenas"
|
||||
? openArenas
|
||||
: mode.id === "downloads"
|
||||
? onOpenDownloads
|
||||
: mode.id === "randomized-bite-sized"
|
||||
? onOpenRandomizedBiteSized
|
||||
: openDungeons}
|
||||
>
|
||||
<b aria-hidden="true">{mode.number}</b>
|
||||
<span><strong>{mode.name}</strong><small>{mode.description}</small></span>
|
||||
<em>Available</em>
|
||||
</ControllerButton>
|
||||
) : (
|
||||
<button
|
||||
key={mode.id}
|
||||
className="front-button main-menu-mode main-menu-mode--coming-soon"
|
||||
type="button"
|
||||
disabled
|
||||
aria-label={`${mode.name}, coming soon`}
|
||||
>
|
||||
<b aria-hidden="true">{mode.number}</b>
|
||||
<span><strong>{mode.name}</strong><small>{mode.description}</small></span>
|
||||
<em>Coming soon</em>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
@@ -214,120 +239,82 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
|
||||
<main className="main-menu-modes">
|
||||
<nav className="main-menu-mode-grid" aria-label="Main menu destinations">
|
||||
<ControllerButton controlId="mode-companion-readiness" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenReadiness}>
|
||||
<b aria-hidden="true">CR</b><span><strong>Companion Readiness</strong><small>Review the certified NPC specialization pool and Playerbots identities.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="mode-boss-testing" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenBossTesting}>
|
||||
<b aria-hidden="true">BT</b><span><strong>Boss Testing</strong><small>Choose a boss or fight a random encounter in an empty testing room.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
{character && <>
|
||||
<ControllerButton controlId="mode-talents" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("talents")}>
|
||||
<b aria-hidden="true">T</b><span><strong>{isRom ? "Skills" : "Talents"}</strong><small>{isRom ? "Review your primary, secondary, and elite skills." : "View your talent trees, spend points, or reset your build."}</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="mode-keybinds" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("keybinds")}>
|
||||
<b aria-hidden="true">K</b><span><strong>Keybinds</strong><small>View and change keyboard or controller bindings.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="mode-bindings" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("bindings")}>
|
||||
<b aria-hidden="true">A</b><span><strong>Button assignments</strong><small>Choose the abilities on your action bars and controller buttons.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
</>}
|
||||
<ControllerButton controlId="mode-cards" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenCards}>
|
||||
<b aria-hidden="true">◆</b><span><strong>Boss Cards</strong><small>Track your collection, rarities, and copies from every dungeon.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
<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 && (
|
||||
<div className="main-menu-gm-stack">
|
||||
<ControllerButton
|
||||
controlId="mode-gm-tools"
|
||||
selectedId={controller.selectedId}
|
||||
select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm"
|
||||
type="button"
|
||||
onClick={openGmAdmin}
|
||||
>
|
||||
<b aria-hidden="true">GM</b>
|
||||
<span><strong>GM Tools</strong><small>Run dungeon route simulations, inspect findings, and review recordings.</small></span>
|
||||
<em>Authorized</em>
|
||||
</ControllerButton>
|
||||
<ControllerButton
|
||||
controlId="mode-balance-lab"
|
||||
selectedId={controller.selectedId}
|
||||
select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--gm"
|
||||
type="button"
|
||||
onClick={onOpenBalanceLab}
|
||||
>
|
||||
<b aria-hidden="true">BL</b>
|
||||
<span><strong>Balance Lab</strong><small>Queue automatic party and boss simulations on the game server.</small></span>
|
||||
<em>Server</em>
|
||||
</ControllerButton>
|
||||
<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) => mode.id === "dungeons" || mode.id === "randomized-bite-sized").map(renderGameMode)}
|
||||
</div>
|
||||
)}
|
||||
{character && (
|
||||
<ControllerButton
|
||||
controlId="mode-class-hall"
|
||||
selectedId={controller.selectedId}
|
||||
select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--class-hall"
|
||||
type="button"
|
||||
onClick={openClassHall}
|
||||
>
|
||||
<b aria-hidden="true">CH</b>
|
||||
<span><strong>Class Hall</strong><small>{isRom ? "Choose a permanent partner class and swap your active ordering." : "Change specialization and retune your heirlooms."}</small></span>
|
||||
<em>{isRom && character.level < 10 ? "Unlocks at 10" : "Open"}</em>
|
||||
</ControllerButton>
|
||||
)}
|
||||
{GAME_MODES.map((mode) => mode.available ? (
|
||||
<ControllerButton
|
||||
key={mode.id}
|
||||
controlId={`mode-${mode.id}`}
|
||||
selectedId={controller.selectedId}
|
||||
select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available"
|
||||
type="button"
|
||||
onClick={mode.id === "manastorms"
|
||||
? openManastorms
|
||||
: mode.id === "arenas"
|
||||
? openArenas
|
||||
: mode.id === "downloads"
|
||||
? onOpenDownloads
|
||||
: mode.id === "randomized-bite-sized"
|
||||
? onOpenRandomizedBiteSized
|
||||
: openDungeons}
|
||||
>
|
||||
<b aria-hidden="true">{mode.number}</b>
|
||||
<span><strong>{mode.name}</strong><small>{mode.description}</small></span>
|
||||
<em>Available</em>
|
||||
</ControllerButton>
|
||||
) : (
|
||||
<button
|
||||
key={mode.id}
|
||||
className="front-button main-menu-mode main-menu-mode--coming-soon"
|
||||
type="button"
|
||||
disabled
|
||||
aria-label={`${mode.name}, coming soon`}
|
||||
>
|
||||
<b aria-hidden="true">{mode.number}</b>
|
||||
<span><strong>{mode.name}</strong><small>{mode.description}</small></span>
|
||||
<em>Coming soon</em>
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="main-menu-card main-menu-card--secondary" aria-labelledby="main-menu-secondary-title">
|
||||
<header><span>02</span><strong id="main-menu-secondary-title">Character & Collection</strong></header>
|
||||
<div className="main-menu-card__grid">
|
||||
{character && <>
|
||||
<ControllerButton controlId="mode-class-hall" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available main-menu-mode--class-hall" type="button" onClick={openClassHall}>
|
||||
<b aria-hidden="true">CH</b><span><strong>Class Hall</strong><small>{isRom ? "Choose a permanent partner class and swap your active ordering." : "Change specialization and retune your heirlooms."}</small></span><em>{isRom && character.level < 10 ? "Unlocks at 10" : "Open"}</em>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="mode-talents" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("talents")}>
|
||||
<b aria-hidden="true">T</b><span><strong>{isRom ? "Skills" : "Talents"}</strong><small>{isRom ? "Review your primary, secondary, and elite skills." : "View your talent trees, spend points, or reset your build."}</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="mode-keybinds" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("keybinds")}>
|
||||
<b aria-hidden="true">K</b><span><strong>Keybinds</strong><small>View and change keyboard or controller bindings.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="mode-bindings" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={() => onOpenEditor("bindings")}>
|
||||
<b aria-hidden="true">A</b><span><strong>Button assignments</strong><small>Choose the abilities on your action bars and controller buttons.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
</>}
|
||||
<ControllerButton controlId="mode-cards" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenCards}>
|
||||
<b aria-hidden="true">◆</b><span><strong>Boss Cards</strong><small>Track your collection, rarities, and copies from every dungeon.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="mode-gm-menu" selectedId={controller.selectedId} select={controller.select}
|
||||
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) => mode.id !== "dungeons" && mode.id !== "randomized-bite-sized").map(renderGameMode)}
|
||||
</div>
|
||||
</section>
|
||||
</nav>
|
||||
{notice && <p className="front-notice main-menu-notice" role="status" aria-live="polite">{notice}</p>}
|
||||
</main>
|
||||
|
||||
<ControllerLegend back />
|
||||
{gmMenuOpen && (
|
||||
<div className="front-modal-backdrop">
|
||||
<section className="main-menu-gm-modal" role="dialog" aria-modal="true" aria-labelledby="gm-menu-title">
|
||||
<header>
|
||||
<div><span>Authorized utilities</span><h2 id="gm-menu-title">GM Menu</h2></div>
|
||||
<ControllerButton controlId="gm-menu-close" selectedId={controller.selectedId} select={controller.select} type="button" onClick={() => setGmMenuOpen(false)}>Close</ControllerButton>
|
||||
</header>
|
||||
<div className="main-menu-gm-grid">
|
||||
<ControllerButton controlId="gm-menu-gm-tools" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--gm" type="button" disabled={!isGm} onClick={openGmAdmin}>
|
||||
<b aria-hidden="true">GM</b><span><strong>GM Tools</strong><small>Run dungeon simulations, inspect findings, and review recordings.</small></span><em>{isGm ? "Authorized" : "GM account required"}</em>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="gm-menu-boss-testing" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--gm" type="button" onClick={onOpenBossTesting}>
|
||||
<b aria-hidden="true">BT</b><span><strong>Boss Testing</strong><small>Fight a selected or random encounter in an empty testing room.</small></span><em>Open</em>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="gm-menu-companion-readiness" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--gm" type="button" onClick={onOpenReadiness}>
|
||||
<b aria-hidden="true">CR</b><span><strong>Companion Readiness</strong><small>Review certified NPC specializations and Playerbots identities.</small></span><em>Open</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>
|
||||
</ControllerButton>
|
||||
<ControllerButton controlId="gm-menu-reports" selectedId={controller.selectedId} select={controller.select}
|
||||
className="front-button main-menu-mode main-menu-mode--gm" 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>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</FrontSurface>
|
||||
}
|
||||
bottom={<LazyLocalCompanionPanel />}
|
||||
|
||||
@@ -33,7 +33,7 @@ export function RandomizedCreatureReadinessScreen({ onClose }: { readonly onClos
|
||||
(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()))
|
||||
|| String(creature.sourceEntry).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) => [
|
||||
@@ -59,7 +59,7 @@ export function RandomizedCreatureReadinessScreen({ onClose }: { readonly onClos
|
||||
<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>Search</span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Name or source 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>
|
||||
@@ -70,7 +70,7 @@ export function RandomizedCreatureReadinessScreen({ onClose }: { readonly onClos
|
||||
<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>
|
||||
<span><strong>{creature.name}</strong><small>{creature.kind} · entry {creature.sourceEntry}</small></span>
|
||||
<em className={`creature-readiness creature-readiness--${statusOf(creature.key)}`}>{CREATURE_READINESS_LABELS[statusOf(creature.key)]}</em>
|
||||
</button>
|
||||
))}
|
||||
@@ -81,7 +81,7 @@ export function RandomizedCreatureReadinessScreen({ onClose }: { readonly onClos
|
||||
{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>
|
||||
<dl><div><dt>Source</dt><dd>{selected.sourceDungeonTitle}</dd></div><div><dt>Entity</dt><dd>{selected.entityId}</dd></div><div><dt>Source entry</dt><dd>{selected.sourceEntry}</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) => (
|
||||
|
||||
@@ -37,6 +37,14 @@ describe("dungeon results and leaderboard", () => {
|
||||
expect(html).toContain("Another random Bite-Sized dungeon");
|
||||
expect(html).toContain("Return to main menu");
|
||||
});
|
||||
it("labels randomized Bite-Sized replay without crossing into the regular pool", () => {
|
||||
const randomizedProgress = { ...progress, id: "randomized-bite-sized-ui-test",
|
||||
dungeonId: "randomized-bite-sized-stormwind-stockade" };
|
||||
const html = renderToStaticMarkup(createElement(DungeonRunResultsDialog, { progress: randomizedProgress,
|
||||
ranking: { scope: "world", loading: false, placement: null },
|
||||
onFinish: noop, onLoot: noop, onRetry: noop, onAnother: noop }));
|
||||
expect(html).toContain("Another Randomized Bite-Sized dungeon");
|
||||
});
|
||||
it("offers both clear filters and expandable party/boss records in the dungeon picker", () => {
|
||||
const html = renderToStaticMarkup(createElement(DungeonLeaderboard, { dungeonTitle: caves.title,
|
||||
data: { entries: [{ ...progress, durationMs: progress.durationMs!, finishedAt: progress.finishedAt!, fullClear: true, rank: 1 }], total: 1 },
|
||||
|
||||
@@ -25,6 +25,10 @@ const shelvedCompanionSource = readFileSync(
|
||||
new URL("./ShelvedCompanionPanel.enabled.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const mainMenuSource = readFileSync(
|
||||
new URL("./MainMenuScreen.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("gameplay panel loading integration", () => {
|
||||
it("loads talent resources behind the main-menu catalog gate, not during gameplay", () => {
|
||||
@@ -52,4 +56,19 @@ describe("gameplay panel loading integration", () => {
|
||||
expect(gameplayPanelsSource).toContain("<PreloadedBossGuidePanel />");
|
||||
expect(companionPanelSource).toContain("<ShelvedCompanionPanel");
|
||||
});
|
||||
|
||||
it("groups primary adventures separately and keeps testing utilities in the GM menu", () => {
|
||||
expect(mainMenuSource).toContain('className="main-menu-card main-menu-card--adventures"');
|
||||
expect(mainMenuSource).toContain('mode.id === "dungeons" || mode.id === "randomized-bite-sized"');
|
||||
expect(mainMenuSource).toContain('controlId="mode-gm-menu"');
|
||||
for (const controlId of [
|
||||
"gm-menu-gm-tools",
|
||||
"gm-menu-boss-testing",
|
||||
"gm-menu-companion-readiness",
|
||||
"gm-menu-balance-lab",
|
||||
"gm-menu-reports",
|
||||
]) {
|
||||
expect(mainMenuSource).toContain(`controlId="${controlId}"`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user