diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index 0705490e..22f8a008 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -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. diff --git a/scripts/content/content-objects.test.mjs b/scripts/content/content-objects.test.mjs index 13f5377c..b3b8343e 100644 --- a/scripts/content/content-objects.test.mjs +++ b/scripts/content/content-objects.test.mjs @@ -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); diff --git a/scripts/content/publish-content.mjs b/scripts/content/publish-content.mjs index 81cc939a..19369ba3 100644 --- a/scripts/content/publish-content.mjs +++ b/scripts/content/publish-content.mjs @@ -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) diff --git a/scripts/content/publish-content.test.mjs b/scripts/content/publish-content.test.mjs index 551a6b78..42f2f78b 100644 --- a/scripts/content/publish-content.test.mjs +++ b/scripts/content/publish-content.test.mjs @@ -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 [ diff --git a/scripts/content/verify-release-bundle.mjs b/scripts/content/verify-release-bundle.mjs index 15cfe37d..5c33367c 100644 --- a/scripts/content/verify-release-bundle.mjs +++ b/scripts/content/verify-release-bundle.mjs @@ -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}`); diff --git a/shared/content-policy.json b/shared/content-policy.json index ac9e886d..d689617d 100644 --- a/shared/content-policy.json +++ b/shared/content-policy.json @@ -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 } diff --git a/src/app/biteSizedDungeonRuntime.test.ts b/src/app/biteSizedDungeonRuntime.test.ts index cf8a4857..bc7a4d74 100644 --- a/src/app/biteSizedDungeonRuntime.test.ts +++ b/src/app/biteSizedDungeonRuntime.test.ts @@ -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, + }); + }); }); diff --git a/src/app/biteSizedDungeonRuntime.ts b/src/app/biteSizedDungeonRuntime.ts index 75df5ef0..bd9cfb04 100644 --- a/src/app/biteSizedDungeonRuntime.ts +++ b/src/app/biteSizedDungeonRuntime.ts @@ -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; +} + +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 { +export async function startAnotherBiteSizedDungeon( + progress: DungeonRunProgress, + dependencies: BiteSizedReplayDependencies = DEFAULT_REPLAY_DEPENDENCIES, +): Promise { 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; } diff --git a/src/game/dungeonCatalog.test.ts b/src/game/dungeonCatalog.test.ts index 9b96f16c..d9e05ef2 100644 --- a/src/game/dungeonCatalog.test.ts +++ b/src/game/dungeonCatalog.test.ts @@ -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); diff --git a/src/game/dungeonCatalog.ts b/src/game/dungeonCatalog.ts index 4dc9bf7e..6f2d3ded 100644 --- a/src/game/dungeonCatalog.ts +++ b/src/game/dungeonCatalog.ts @@ -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); } diff --git a/src/game/dungeonContentSource.ts b/src/game/dungeonContentSource.ts index 4341acfa..147d2cbd 100644 --- a/src/game/dungeonContentSource.ts +++ b/src/game/dungeonContentSource.ts @@ -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> = Object.freeze({ [BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID, [RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID, diff --git a/src/game/dungeons/randomizedBiteSizedStockades.ts b/src/game/dungeons/randomizedBiteSizedStockades.ts index 016157b5..974889f8 100644 --- a/src/game/dungeons/randomizedBiteSizedStockades.ts +++ b/src/game/dungeons/randomizedBiteSizedStockades.ts @@ -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()]; } diff --git a/src/game/randomizedBiteSizedStockades.test.ts b/src/game/randomizedBiteSizedStockades.test.ts index 5570febe..3cccec89 100644 --- a/src/game/randomizedBiteSizedStockades.test.ts +++ b/src/game/randomizedBiteSizedStockades.test.ts @@ -19,10 +19,10 @@ function create(seed: string) { return createRandomizedBiteSizedStockadesDefinition(source, seed, catalog); } -function bossEntityServerEntry(definition: ReturnType): number | undefined { +function bossEntityId(definition: ReturnType): 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(); + const bossEntityIds = new Set(catalog.filter((candidate) => candidate.kind === "boss").map((candidate) => candidate.entityId)); + const observedBossEntityIds = new Set(); const observedTrashLineups = new Set(); 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); diff --git a/src/styles.css b/src/styles.css index 2b5035f8..45b37958 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/src/ui/ContentManagerScreen.test.ts b/src/ui/ContentManagerScreen.test.ts index 7518df70..b4f7c216 100644 --- a/src/ui/ContentManagerScreen.test.ts +++ b/src/ui/ContentManagerScreen.test.ts @@ -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"); }); }); diff --git a/src/ui/DungeonRunResults.tsx b/src/ui/DungeonRunResults.tsx index ed57b1e9..21b37fe9 100644 --- a/src/ui/DungeonRunResults.tsx +++ b/src/ui/DungeonRunResults.tsx @@ -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