Update Healer Man server

This commit is contained in:
phenom committed 2026-09-20 18:08:43 -04:00
1 parent 3e30af4c98
commit 97ee0c13f9
25 files changed
+576 -179

No files matched your search

+13
View File
@@ -3699,3 +3699,16 @@ Vite's existing large-chunk advisory remains informational.
- A production Wailing Caverns smoke run rendered the player and all four party - A production Wailing Caverns smoke run rendered the player and all four party
members with intact bodies and starter weapon silhouettes. Evidence is in members with intact bodies and starter weapon silhouettes. Evidence is in
`playtest-artifacts/character-shell/04-in-dungeon.png`. `playtest-artifacts/character-shell/04-in-dungeon.png`.
## Thor main-menu redesign (2026-09-20)
- The `960 x 540` top display now gives the destination grid the wide left pane
and uses a 288-pixel right rail for the selected character's level, name,
class, and specialization. The character-model preview no longer mounts on
this screen; authenticated sessions open the online-group invite panel below
the identity card.
- A live browser pass measured a full `960 x 540` surface with no horizontal or
document overflow. The destination pane scrolls independently, the identity
card remains fully visible, no character-preview node mounts, and all removed
introductory copy is absent. Nineteen focused tests and the production build
passed.
+7
View File
@@ -25,6 +25,12 @@ export const CONTENT_SCHEMA_VERSION = 1;
export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0"; export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0";
export const WOW_CAMPAIGN_DUNGEON_COUNT = 54; export const WOW_CAMPAIGN_DUNGEON_COUNT = 54;
export const BUNDLED_ANDROID_PACKS = Object.freeze([...contentPolicy.bootstrapPackIds]); export const BUNDLED_ANDROID_PACKS = Object.freeze([...contentPolicy.bootstrapPackIds]);
export const RANDOMIZED_BITE_SIZED_PACK_DEFINITION = Object.freeze({
id: "activity:randomized-bite-sized",
label: "Randomized Bite-Sized Base",
bundledOnAndroid: false,
dependencies: ["dungeon:stormwind-stockade"],
});
const RUNTIME_EXTENSIONS = new Set([ const RUNTIME_EXTENSIONS = new Set([
".avif", ".glb", ".js", ".json", ".ktx2", ".mp3", ".ogg", ".png", ".avif", ".glb", ".js", ".json", ".ktx2", ".mp3", ".ogg", ".png",
@@ -435,6 +441,7 @@ export async function publishContent(options = {}) {
: []; : [];
const packDefinitions = [ const packDefinitions = [
...campaignPackDefinitions, ...campaignPackDefinitions,
...(campaignDefinitions ? [RANDOMIZED_BITE_SIZED_PACK_DEFINITION] : []),
...(options.packDefinitions ?? []), ...(options.packDefinitions ?? []),
]; ];
if (campaignDefinitions) assertCampaignDungeonManifest(createContentManifest(files, { ...options, packDefinitions }), campaignDefinitions); if (campaignDefinitions) assertCampaignDungeonManifest(createContentManifest(files, { ...options, packDefinitions }), campaignDefinitions);
+10
View File
@@ -6,6 +6,7 @@ import test from "node:test";
import { import {
BUNDLED_ANDROID_PACKS, BUNDLED_ANDROID_PACKS,
PROJECT_ROOT, PROJECT_ROOT,
RANDOMIZED_BITE_SIZED_PACK_DEFINITION,
assertCampaignDungeonManifest, assertCampaignDungeonManifest,
contentPackForPath, contentPackForPath,
createContentManifest, createContentManifest,
@@ -14,6 +15,15 @@ import {
scanContentSources, scanContentSources,
} from "./publish-content.mjs"; } from "./publish-content.mjs";
test("gives randomized bite-sized its base location dependency closure", () => {
assert.deepEqual(RANDOMIZED_BITE_SIZED_PACK_DEFINITION, {
id: "activity:randomized-bite-sized",
label: "Randomized Bite-Sized Base",
bundledOnAndroid: false,
dependencies: ["dungeon:stormwind-stockade"],
});
});
test("assigns stable player-facing packs from logical asset paths", () => { 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/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"), "dungeon:wailing-caverns");
+1 -1
View File
@@ -104,7 +104,7 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
saveRandomizedCreatureReadiness(account, input) { saveRandomizedCreatureReadiness(account, input) {
if (!isGameMaster(account)) throw new ErrorType("Game Master access is required.", 403, "gm_required"); if (!isGameMaster(account)) throw new ErrorType("Game Master access is required.", 403, "gm_required");
if (input?.dungeonId !== "randomized-bite-sized-stormwind-stockade" if (input?.dungeonId !== "randomized-bite-sized-stormwind-stockade"
|| typeof input?.creatureKey !== "string" || !input.creatureKey.startsWith("stormwind-stockade:") || typeof input?.creatureKey !== "string" || !/^(wow|rom):(mob|boss):[1-9][0-9]*$/.test(input.creatureKey)
|| input.creatureKey.length > 256 || typeof input?.creatureName !== "string" || input.creatureKey.length > 256 || typeof input?.creatureName !== "string"
|| !input.creatureName.trim() || input.creatureName.length > 128 || !input.creatureName.trim() || input.creatureName.length > 128
|| !["mob", "boss"].includes(input.creatureKind)) { || !["mob", "boss"].includes(input.creatureKind)) {
+2 -2
View File
@@ -104,7 +104,7 @@ test("persists randomized mob and boss readiness and requires a server-authentic
const gm = (await database.register("Phenom", "readiness-admin")).account; const gm = (await database.register("Phenom", "readiness-admin")).account;
const player = (await database.register("ReadinessPlayer", "readiness-player")).account; const player = (await database.register("ReadinessPlayer", "readiness-player")).account;
const creature = { const creature = {
creatureKey: "stormwind-stockade:entity-1711", creatureKey: "wow:mob:3868",
dungeonId: "randomized-bite-sized-stormwind-stockade", dungeonId: "randomized-bite-sized-stormwind-stockade",
creatureName: "Defias Convict", creatureName: "Defias Convict",
creatureKind: "mob", creatureKind: "mob",
@@ -140,7 +140,7 @@ test("serves public reports and GM-only status API with shelved modes disabled",
const labels = await (await fetch(`${origin}/api/boss-testing/reviews`)).json(); const labels = await (await fetch(`${origin}/api/boss-testing/reviews`)).json();
assert.equal(labels.reviews[0].status, "ready"); assert.equal(labels.reviews[0].status, "ready");
const gm = await runtime.database.register("Phenom", "report-admin"); const gm = await runtime.database.register("Phenom", "report-admin");
const creature = { creatureKey: "stormwind-stockade:entity-1711", dungeonId: "randomized-bite-sized-stormwind-stockade", const creature = { creatureKey: "wow:mob:3868", dungeonId: "randomized-bite-sized-stormwind-stockade",
creatureName: "Defias Convict", creatureKind: "mob", status: "ready" }; creatureName: "Defias Convict", creatureKind: "mob", status: "ready" };
const playerDenied = await fetch(`${origin}/api/gm/randomized-creature-readiness`, { method: "PUT", const playerDenied = await fetch(`${origin}/api/gm/randomized-creature-readiness`, { method: "PUT",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${player.token}` }, body: JSON.stringify(creature) }); headers: { "Content-Type": "application/json", Authorization: `Bearer ${player.token}` }, body: JSON.stringify(creature) });
+2 -1
View File
@@ -11,7 +11,8 @@
"core:arenas" "core:arenas"
], ],
"hiddenContentPackPrefixes": [ "hiddenContentPackPrefixes": [
"manastorm:" "manastorm:",
"activity:"
], ],
"maxBootstrapStoredBytes": 83886080 "maxBootstrapStoredBytes": 83886080
} }
+15
View File
@@ -26,6 +26,7 @@ const MANASTORM_ACTIVITY: OnlineGroupActivity = {
function entryDependencies(enterDungeon: () => boolean) { function entryDependencies(enterDungeon: () => boolean) {
return { return {
prepareRandomizedDungeonContent: vi.fn(async () => undefined),
installDungeonRuntime: vi.fn(async () => undefined), installDungeonRuntime: vi.fn(async () => undefined),
enterDungeon: vi.fn(enterDungeon), enterDungeon: vi.fn(enterDungeon),
installManastormRuntime: vi.fn(async () => undefined), installManastormRuntime: vi.fn(async () => undefined),
@@ -48,6 +49,20 @@ describe("online group activity entry", () => {
difficultyId: "heroic", bossDifficulty: 9, freshRun: true, runId: DUNGEON_ACTIVITY.id, difficultyId: "heroic", bossDifficulty: 9, freshRun: true, runId: DUNGEON_ACTIVITY.id,
}); });
}); });
it("downloads the seeded randomized creature packs before shared entry", async () => {
const dependencies = entryDependencies(() => true);
const activity = {
...DUNGEON_ACTIVITY,
id: "shared-randomized-seed",
contentId: "randomized-bite-sized-stormwind-stockade",
};
await enterOnlineGroupActivity(activity, "healer", vi.fn(), dependencies);
expect(dependencies.prepareRandomizedDungeonContent).toHaveBeenCalledWith(activity.id);
expect(dependencies.prepareRandomizedDungeonContent.mock.invocationCallOrder[0])
.toBeLessThan(dependencies.enterDungeon.mock.invocationCallOrder[0]);
});
it("leaves a dungeon activity unconsumed when dungeon entry fails", async () => { it("leaves a dungeon activity unconsumed when dungeon entry fails", async () => {
const consumeActivity = vi.fn(); const consumeActivity = vi.fn();
const dependencies = entryDependencies(() => false); const dependencies = entryDependencies(() => false);
+9
View File
@@ -12,11 +12,13 @@ import { useContentManagerSnapshot } from "../content/useContentManager";
import { SHELVED_MODES_ENABLED } from "./releaseFeatures"; import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge"; import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime"; import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime";
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../game/dungeonContentSource";
const GROUP_POLL_MS = 2_500; const GROUP_POLL_MS = 2_500;
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000; const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
interface OnlineGroupActivityEntryDependencies { interface OnlineGroupActivityEntryDependencies {
readonly prepareRandomizedDungeonContent: (seed: string) => Promise<void>;
readonly installDungeonRuntime: () => Promise<void>; readonly installDungeonRuntime: () => Promise<void>;
readonly enterDungeon: (dungeonId: string, role?: PartyRole, settings?: DungeonEntrySettings) => boolean; readonly enterDungeon: (dungeonId: string, role?: PartyRole, settings?: DungeonEntrySettings) => boolean;
readonly installManastormRuntime: () => Promise<void>; readonly installManastormRuntime: () => Promise<void>;
@@ -24,6 +26,10 @@ interface OnlineGroupActivityEntryDependencies {
} }
const DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES: OnlineGroupActivityEntryDependencies = { const DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES: OnlineGroupActivityEntryDependencies = {
prepareRandomizedDungeonContent: async (seed) => {
const { ensureRandomizedBiteSizedRunContent } = await import("./randomizedBiteSizedContent");
await ensureRandomizedBiteSizedRunContent(seed);
},
installDungeonRuntime: async () => { installDungeonRuntime: async () => {
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime"); const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
installDungeonShellRuntime(); installDungeonShellRuntime();
@@ -47,6 +53,9 @@ export async function enterOnlineGroupActivity(
): Promise<boolean> { ): Promise<boolean> {
let entered: boolean; let entered: boolean;
if (activity.type === "dungeon") { if (activity.type === "dungeon") {
if (activity.contentId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) {
await dependencies.prepareRandomizedDungeonContent(activity.id);
}
await dependencies.installDungeonRuntime(); await dependencies.installDungeonRuntime();
entered = dependencies.enterDungeon(activity.contentId, role, { entered = dependencies.enterDungeon(activity.contentId, role, {
difficultyId: activity.difficultyId, difficultyId: activity.difficultyId,
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from "vitest";
import {
ensureRandomizedBiteSizedRunContent,
randomizedBiteSizedModelUrls,
} from "./randomizedBiteSizedContent";
describe("randomized bite-sized run content", () => {
it("derives only the distinct model URLs selected by the shared run seed", () => {
const first = randomizedBiteSizedModelUrls("asset-seed");
const second = randomizedBiteSizedModelUrls("asset-seed");
expect(first).toEqual(second);
expect(first.length).toBeGreaterThan(1);
expect(new Set(first).size).toBe(first.length);
expect(first.every((url) => url.startsWith("/assets/") && url.endsWith(".glb"))).toBe(true);
});
it("installs the base location and only the rolled creature model packs before entry", async () => {
const installPack = vi.fn(async () => undefined);
const installUrls = vi.fn(async () => undefined);
await ensureRandomizedBiteSizedRunContent("download-seed", {
development: false,
installPack,
installUrls,
packIsUsable: () => false,
packHasUpdate: () => false,
});
expect(installPack).toHaveBeenCalledWith("activity:randomized-bite-sized");
expect(installUrls).toHaveBeenCalledWith(randomizedBiteSizedModelUrls("download-seed"));
expect(installPack.mock.invocationCallOrder[0]).toBeLessThan(installUrls.mock.invocationCallOrder[0]);
});
it("skips network installation in the unrestricted development asset server", async () => {
const installPack = vi.fn(async () => undefined);
const installUrls = vi.fn(async () => undefined);
await ensureRandomizedBiteSizedRunContent("dev-seed", {
development: true,
installPack,
installUrls,
packIsUsable: () => false,
packHasUpdate: () => false,
});
expect(installPack).not.toHaveBeenCalled();
expect(installUrls).not.toHaveBeenCalled();
});
});
+47
View File
@@ -0,0 +1,47 @@
import {
contentPackHasUpdate,
contentPackIsUsable,
installContentForLogicalUrls,
installContentPack,
} from "../content/contentManager";
import {
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
dungeonContentPackId,
} from "../game/dungeonContentSource";
import { randomizedBiteSizedDefinitionForSeed } from "../game/dungeonRegistry";
export function randomizedBiteSizedModelUrls(seed: string): readonly string[] {
const definition = randomizedBiteSizedDefinitionForSeed(seed);
return [...new Set(Object.values(definition.entities).flatMap((entity) => (
entity.visual.model?.url ? [entity.visual.model.url] : []
)))].sort();
}
interface RandomizedContentDependencies {
readonly development: boolean;
readonly installPack: (packId: string) => Promise<void>;
readonly installUrls: (urls: readonly string[]) => Promise<void>;
readonly packIsUsable: (packId: string) => boolean;
readonly packHasUpdate: (packId: string) => boolean;
}
const DEFAULT_DEPENDENCIES: RandomizedContentDependencies = {
development: import.meta.env.DEV,
installPack: installContentPack,
installUrls: installContentForLogicalUrls,
packIsUsable: contentPackIsUsable,
packHasUpdate: contentPackHasUpdate,
};
/** Downloads the rolled location and creature packs before the seeded run enters gameplay. */
export async function ensureRandomizedBiteSizedRunContent(
seed: string,
dependencies: RandomizedContentDependencies = DEFAULT_DEPENDENCIES,
): Promise<void> {
if (dependencies.development) return;
const basePackId = dungeonContentPackId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
if (!dependencies.packIsUsable(basePackId) || dependencies.packHasUpdate(basePackId)) {
await dependencies.installPack(basePackId);
}
await dependencies.installUrls(randomizedBiteSizedModelUrls(seed));
}
+21
View File
@@ -2,6 +2,7 @@ import { Capacitor } from "@capacitor/core";
import { import {
appVersionMeetsMinimum, appVersionMeetsMinimum,
changedFilesForPack, changedFilesForPack,
contentPackIdsForLogicalUrls,
filesForContentPack, filesForContentPack,
parseContentManifest, parseContentManifest,
type ContentManifest, type ContentManifest,
@@ -636,6 +637,26 @@ export async function installContentPack(packId: string): Promise<void> {
} }
} }
/** Installs only the packs owning the selected run's logical model URLs. */
export async function installContentForLogicalUrls(logicalUrls: readonly string[]): Promise<void> {
await validateInstalledContentStorage();
const logicalPaths = logicalUrls.map((logicalUrl) => {
try { return new URL(logicalUrl, "https://healer-man.invalid").pathname; } catch { return logicalUrl; }
});
const alreadyAvailable = logicalPaths.every((logicalPath) => (
Boolean(snapshot.bundledManifest?.files[logicalPath])
|| Boolean(installedState.files[logicalPath] && validatedInstalledFiles.has(logicalPath))
));
if (alreadyAvailable) return;
const manifest = snapshot.manifest ?? await checkForContentUpdates();
if (!manifest) throw new Error(snapshot.error || "Content server is unavailable.");
for (const packId of contentPackIdsForLogicalUrls(manifest, logicalUrls)) {
if (!contentPackIsUsable(packId) || contentPackHasUpdate(packId)) {
await installContentPack(packId);
}
}
}
export async function removeContentPack(packId: string): Promise<void> { export async function removeContentPack(packId: string): Promise<void> {
if (contentPackIsBundled(packId)) return; if (contentPackIsBundled(packId)) return;
const remainingFiles = { ...installedState.files }; const remainingFiles = { ...installedState.files };
+25
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { import {
appVersionMeetsMinimum, appVersionMeetsMinimum,
changedFilesForPack, changedFilesForPack,
contentPackIdsForLogicalUrls,
filesForContentPack, filesForContentPack,
parseContentManifest, parseContentManifest,
type ContentManifest, type ContentManifest,
@@ -46,4 +47,28 @@ describe("content manifest", () => {
"/assets/a.glb": { sha256: "a".repeat(64) }, "/assets/a.glb": { sha256: "a".repeat(64) },
}).map(([logicalPath]) => logicalPath)).toEqual(["/assets/b.glb"]); }).map(([logicalPath]) => logicalPath)).toEqual(["/assets/b.glb"]);
}); });
it("resolves a seeded run's logical models to distinct owning packs", () => {
const withSecondPack: ContentManifest = {
...manifest,
packs: [...manifest.packs, {
id: "shared:creatures", label: "Shared", bytes: 1, fileCount: 1,
bundledOnAndroid: false, dependencies: [],
}],
files: {
...manifest.files,
"/assets/shared.glb": {
packId: "shared:creatures", url: "/content/objects/shared.glb",
sha256: "c".repeat(64), bytes: 1,
},
},
};
expect(contentPackIdsForLogicalUrls(withSecondPack, [
"/assets/shared.glb",
"/assets/a.glb",
"/assets/shared.glb?cache=1",
])).toEqual(["dungeon:test", "shared:creatures"]);
expect(() => contentPackIdsForLogicalUrls(withSecondPack, ["/assets/missing.glb"]))
.toThrow(/missing randomized asset/i);
});
}); });
+20
View File
@@ -32,6 +32,26 @@ export interface ContentManifest {
readonly files: Readonly<Record<string, ContentManifestFile>>; readonly files: Readonly<Record<string, ContentManifestFile>>;
} }
/** Resolves runtime logical asset URLs to the independently installable packs that own them. */
export function contentPackIdsForLogicalUrls(
manifest: ContentManifest,
logicalUrls: readonly string[],
): readonly string[] {
const packIds = new Set<string>();
for (const logicalUrl of logicalUrls) {
let pathname: string;
try {
pathname = new URL(logicalUrl, "https://healer-man.invalid").pathname;
} catch {
throw new Error(`Invalid content asset URL: ${logicalUrl}`);
}
const entry = manifest.files[pathname];
if (!entry) throw new Error(`The content catalog is missing randomized asset ${pathname}.`);
packIds.add(entry.packId);
}
return [...packIds].sort();
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value); return Boolean(value) && typeof value === "object" && !Array.isArray(value);
} }
+1 -1
View File
@@ -12,7 +12,7 @@ describe("derived dungeon content sources", () => {
expect(dungeonContentSourceId(BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe(STORMWIND_STOCKADE_DUNGEON_ID); expect(dungeonContentSourceId(BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe(STORMWIND_STOCKADE_DUNGEON_ID);
expect(dungeonContentPackId(BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe("dungeon:stormwind-stockade"); expect(dungeonContentPackId(BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe("dungeon:stormwind-stockade");
expect(dungeonContentSourceId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe(STORMWIND_STOCKADE_DUNGEON_ID); expect(dungeonContentSourceId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe(STORMWIND_STOCKADE_DUNGEON_ID);
expect(dungeonContentPackId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe("dungeon:stormwind-stockade"); expect(dungeonContentPackId(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe("activity:randomized-bite-sized");
expect(dungeonContentPackId("deadmines")).toBe("dungeon:deadmines"); expect(dungeonContentPackId("deadmines")).toBe("dungeon:deadmines");
}); });
}); });
+4
View File
@@ -1,6 +1,7 @@
export const STORMWIND_STOCKADE_DUNGEON_ID = "stormwind-stockade"; export const STORMWIND_STOCKADE_DUNGEON_ID = "stormwind-stockade";
export const BITE_SIZED_STOCKADES_DUNGEON_ID = "bite-sized-stormwind-stockade"; 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_STOCKADES_DUNGEON_ID = "randomized-bite-sized-stormwind-stockade";
export const RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID = "activity:randomized-bite-sized";
const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.freeze({ const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.freeze({
[BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID, [BITE_SIZED_STOCKADES_DUNGEON_ID]: STORMWIND_STOCKADE_DUNGEON_ID,
@@ -18,5 +19,8 @@ export function dungeonContentSourceId(dungeonId: string): string {
} }
export function dungeonContentPackId(dungeonId: string): string { export function dungeonContentPackId(dungeonId: string): string {
if (dungeonId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) {
return RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID;
}
return `dungeon:${dungeonContentSourceId(dungeonId)}`; return `dungeon:${dungeonContentSourceId(dungeonId)}`;
} }
+17 -3
View File
@@ -28,7 +28,10 @@ import {
dungeonContentSourceId, dungeonContentSourceId,
} from "./dungeonContentSource"; } from "./dungeonContentSource";
import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades"; import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades";
import { createRandomizedBiteSizedStockadesDefinition } from "./dungeons/randomizedBiteSizedStockades"; import {
createRandomizedBiteSizedStockadesDefinition,
randomizedBiteSizedCreatureCatalog,
} from "./dungeons/randomizedBiteSizedStockades";
export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults"; export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
@@ -201,8 +204,19 @@ const adaptedSourceRegistry = Object.fromEntries(adaptedSourceEntries) as Readon
const stockadesDefinition = adaptedSourceRegistry[STORMWIND_STOCKADE_DUNGEON_ID]; const stockadesDefinition = adaptedSourceRegistry[STORMWIND_STOCKADE_DUNGEON_ID];
if (!stockadesDefinition) throw new Error("Stormwind Stockade is required for its bite-sized variant."); if (!stockadesDefinition) throw new Error("Stormwind Stockade is required for its bite-sized variant.");
export const RANDOMIZED_BITE_SIZED_CREATURE_CATALOG = Object.freeze(
randomizedBiteSizedCreatureCatalog(adaptedSourceEntries.map(([, definition]) => definition)),
);
export function randomizedBiteSizedDefinitionForSeed(seed: string): DungeonDefinition {
return createRandomizedBiteSizedStockadesDefinition(
stockadesDefinition,
seed,
RANDOMIZED_BITE_SIZED_CREATURE_CATALOG,
);
}
const biteSizedStockadesTemplate = createBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template"); const biteSizedStockadesTemplate = createBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template");
const randomizedBiteSizedStockadesTemplate = createRandomizedBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template"); const randomizedBiteSizedStockadesTemplate = randomizedBiteSizedDefinitionForSeed("catalog-template");
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [ const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
...adaptedSourceEntries, ...adaptedSourceEntries,
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate], [biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
@@ -244,7 +258,7 @@ export function prepareDungeonSessionDefinition(id: string, seed: string): Dunge
} }
const definition = id === BITE_SIZED_STOCKADES_DUNGEON_ID const definition = id === BITE_SIZED_STOCKADES_DUNGEON_ID
? createBiteSizedStockadesDefinition(stockadesDefinition, seed) ? createBiteSizedStockadesDefinition(stockadesDefinition, seed)
: createRandomizedBiteSizedStockadesDefinition(stockadesDefinition, seed); : randomizedBiteSizedDefinitionForSeed(seed);
sessionDungeonDefinitions.set(id, definition); sessionDungeonDefinitions.set(id, definition);
return definition; return definition;
} }
+135 -57
View File
@@ -1,13 +1,7 @@
import type { DungeonDefinition } from "../dungeonTypes"; import type { DungeonDefinition } from "../dungeonTypes";
import type { PopulationEntityDefinition } from "../mobPopulation"; import type { PopulationEntityDefinition } from "../mobPopulation";
import { import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../dungeonContentSource";
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, import { createBiteSizedStockadesDefinition } from "./biteSizedStockades";
STORMWIND_STOCKADE_DUNGEON_ID,
} from "../dungeonContentSource";
import {
BITE_SIZED_STOCKADES_BOSS_CANDIDATES,
createBiteSizedStockadesDefinition,
} from "./biteSizedStockades";
export type RandomizedCreatureKind = "mob" | "boss"; export type RandomizedCreatureKind = "mob" | "boss";
@@ -18,7 +12,15 @@ export interface RandomizedBiteSizedCreature {
readonly entityId: string; readonly entityId: string;
readonly name: string; readonly name: string;
readonly kind: RandomizedCreatureKind; readonly kind: RandomizedCreatureKind;
readonly serverEntry: number | null; readonly serverEntry: number;
readonly definition: PopulationEntityDefinition;
}
function creatureKey(source: DungeonDefinition, entity: PopulationEntityDefinition): string | null {
const serverEntry = entity.combat?.serverEntry;
if (!serverEntry) return null;
const catalog = source.expansion === "runewaker" ? "rom" : "wow";
return `${catalog}:${entity.kind}:${serverEntry}`;
} }
function seedHash(seed: string): number { function seedHash(seed: string): number {
@@ -30,64 +32,133 @@ function seedHash(seed: string): number {
return hash >>> 0; return hash >>> 0;
} }
function placedEntityIds(source: DungeonDefinition): ReadonlySet<string> { function seededRandom(seed: string): () => number {
return new Set([ let state = seedHash(seed) || 0x6d2b79f5;
...source.staticSpawns.map((spawn) => spawn.entityId), return () => {
...source.roamingPacks.flatMap((pack) => pack.members.map((member) => member.entityId)), state += 0x6d2b79f5;
]); let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 0x1_0000_0000;
};
} }
/** The immutable review catalog used by both seeded generation and the GM readiness screen. */ function seededShuffle<T>(values: readonly T[], seed: string): T[] {
export function randomizedBiteSizedCreatureCatalog( const shuffled = [...values];
source: DungeonDefinition, const random = seededRandom(seed);
): readonly RandomizedBiteSizedCreature[] { for (let index = shuffled.length - 1; index > 0; index -= 1) {
if (source.id !== STORMWIND_STOCKADE_DUNGEON_ID) { const swapIndex = Math.floor(random() * (index + 1));
throw new Error(`Randomized Bite-Sized Stockades requires ${STORMWIND_STOCKADE_DUNGEON_ID}.`); [shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]];
} }
const placed = placedEntityIds(source); return shuffled;
const bossEntityIds = new Set(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((candidate) => {
const spawn = source.staticSpawns.find((entry) => entry.id === candidate.sourceSpawnId);
if (!spawn) throw new Error(`Stockades boss source is missing: ${candidate.sourceSpawnId}`);
return spawn.entityId;
}));
return Object.values(source.entities)
.filter((entity) => placed.has(entity.id) && (entity.kind === "mob" || bossEntityIds.has(entity.id)))
.map((entity) => ({
key: `${source.id}:${entity.id}`,
sourceDungeonId: source.id,
sourceDungeonTitle: source.title,
entityId: entity.id,
name: entity.name,
kind: entity.kind,
serverEntry: entity.combat?.serverEntry ?? null,
}))
.sort((left, right) => left.kind.localeCompare(right.kind) || left.name.localeCompare(right.name));
} }
/** /**
* Rerolls every ordinary corridor spawn from the complete placed Stockades mob * The immutable cross-dungeon review catalog used by seeded generation and the
* pool. The existing bite-sized builder remains the authority for geometry, * GM readiness screen. Installed state never limits this pool; selected model
* navigation, sealed wings, and the independently seeded five-boss roll. * packs are resolved and downloaded from the content manifest before entry.
*/
export function randomizedBiteSizedCreatureCatalog(
sources: readonly DungeonDefinition[],
): readonly RandomizedBiteSizedCreature[] {
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 [];
return [{
key,
sourceDungeonId: source.id,
sourceDungeonTitle: source.title,
entityId: entity.id,
name: entity.name,
kind: entity.kind,
serverEntry,
definition: entity,
} satisfies RandomizedBiteSizedCreature];
}))
.sort((left, right) => left.kind.localeCompare(right.kind)
|| left.name.localeCompare(right.name)
|| left.serverEntry - right.serverEntry
|| left.sourceDungeonId.localeCompare(right.sourceDungeonId));
return [...new Map(candidates.map((candidate) => [candidate.key, candidate])).values()];
}
function dealMobs(
source: DungeonDefinition,
spawns: readonly { readonly entityId: string }[],
candidates: readonly RandomizedBiteSizedCreature[],
seed: string,
): readonly RandomizedBiteSizedCreature[] {
const dealt = seededShuffle(candidates, seed);
for (let index = 0; index < spawns.length; index += 1) {
const original = source.entities[spawns[index].entityId];
const originalKey = original ? creatureKey(source, original) : null;
if (dealt[index]?.key !== originalKey) continue;
const swapIndex = dealt.findIndex((candidate, candidateIndex) => (
candidateIndex > index && candidate.key !== originalKey
));
if (swapIndex < 0) throw new Error("Randomized Bite-Sized Stockades could not replace an original mob.");
[dealt[index], dealt[swapIndex]] = [dealt[swapIndex], dealt[index]];
}
return dealt.slice(0, spawns.length);
}
function runtimeEntity(candidate: RandomizedBiteSizedCreature): PopulationEntityDefinition {
// Boss source ids remain intact so their existing loot table and collection
// card can still resolve. Trash ids are namespaced away from the location.
const id = candidate.kind === "boss"
? candidate.entityId
: `randomized:${candidate.key}`;
const combat = candidate.definition.combat ? {
...candidate.definition.combat,
// The identity and attacks are randomized, while durability and pressure
// remain normalized to this short leveling dungeon's current party level.
healthMultiplier: 1,
damageMultiplier: 1,
} : undefined;
return { ...candidate.definition, id, ...(combat ? { combat } : {}) };
}
/**
* Keeps the reviewed Stockades geometry and navigation, then deals a different
* cross-dungeon creature to every corridor slot and rolls the boss separately.
*/ */
export function createRandomizedBiteSizedStockadesDefinition( export function createRandomizedBiteSizedStockadesDefinition(
source: DungeonDefinition, source: DungeonDefinition,
seed: string, seed: string,
catalog: readonly RandomizedBiteSizedCreature[],
): DungeonDefinition { ): DungeonDefinition {
const base = createBiteSizedStockadesDefinition(source, seed); const base = createBiteSizedStockadesDefinition(source, `${seed}:objective-slot`);
const catalog = randomizedBiteSizedCreatureCatalog(source);
const mobCandidates = catalog.filter((candidate) => candidate.kind === "mob");
if (!mobCandidates.length) throw new Error("Randomized Bite-Sized Stockades has no ordinary mob candidates.");
const bossSpawnId = base.bosses[0]?.id; const bossSpawnId = base.bosses[0]?.id;
if (!bossSpawnId) throw new Error("Randomized Bite-Sized Stockades has no final boss slot.");
const trashSpawns = base.staticSpawns.filter((spawn) => spawn.id !== bossSpawnId);
const mobPool = catalog.filter((candidate) => candidate.kind === "mob");
if (mobPool.length < trashSpawns.length) {
throw new Error(`Randomized Bite-Sized Stockades needs ${trashSpawns.length} distinct mob candidates.`);
}
const mobCandidates = dealMobs(source, trashSpawns, mobPool, `${seed}:trash`);
const originalBossSpawn = source.staticSpawns.find((spawn) => spawn.id === bossSpawnId);
const originalBoss = originalBossSpawn ? source.entities[originalBossSpawn.entityId] : undefined;
const originalBossKey = originalBoss ? creatureKey(source, originalBoss) : null;
const bossCandidate = seededShuffle(
catalog.filter((candidate) => candidate.kind === "boss"),
`${seed}:boss`,
).find((candidate) => candidate.key !== originalBossKey);
if (!bossCandidate) throw new Error("Randomized Bite-Sized Stockades has no boss candidates.");
const selectedMobs = new Map(trashSpawns.map((spawn, index) => [spawn.id, mobCandidates[index]]));
const bossEntity = runtimeEntity(bossCandidate);
const randomizedSpawns = base.staticSpawns.map((spawn) => { const randomizedSpawns = base.staticSpawns.map((spawn) => {
if (spawn.id === bossSpawnId) return spawn; const candidate = selectedMobs.get(spawn.id);
const candidate = mobCandidates[seedHash(`${seed}:${spawn.id}`) % mobCandidates.length]; return { ...spawn, entityId: candidate ? runtimeEntity(candidate).id : bossEntity.id };
return { ...spawn, entityId: candidate.entityId };
}); });
const usedEntityIds = new Set(randomizedSpawns.map((spawn) => spawn.entityId)); const selectedCandidates = [...selectedMobs.values(), bossCandidate];
const entities = Object.fromEntries( const entities = Object.fromEntries(selectedCandidates.map((candidate) => {
Object.entries(source.entities).filter(([entityId]) => usedEntityIds.has(entityId)), const entity = runtimeEntity(candidate);
) as Readonly<Record<string, PopulationEntityDefinition>>; return [entity.id, entity];
})) as Readonly<Record<string, PopulationEntityDefinition>>;
const boss = { ...base.bosses[0], name: bossCandidate.name };
return { return {
...base, ...base,
@@ -97,11 +168,16 @@ export function createRandomizedBiteSizedStockadesDefinition(
...base.presentation, ...base.presentation,
location: "Stormwind Stockade · Shifting Cellblock", location: "Stormwind Stockade · Shifting Cellblock",
theme: "Randomized Bite-Sized", theme: "Randomized Bite-Sized",
summary: "Every enemy slot and the final warden are rerolled for a fast, unpredictable one-boss run.", summary: "Every enemy slot is dealt a distinct cross-dungeon creature, followed by an independently randomized boss.",
loadingMessage: "Shuffling prisoners, guards, and the final warden...", loadingMessage: "Choosing a location and shuffling every creature slot...",
}, },
areas: base.areas.map((area) => area.id === "bite-sized-boss-room"
? { ...area, name: `${bossCandidate.name}'s Cell` }
: area),
entities, entities,
staticSpawns: randomizedSpawns, staticSpawns: randomizedSpawns,
bosses: [boss],
objectiveOrder: [boss],
encounters: (base.encounters ?? []).map((encounter) => ({ encounters: (base.encounters ?? []).map((encounter) => ({
...encounter, ...encounter,
id: `${RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID}:boss-progression`, id: `${RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID}:boss-progression`,
@@ -109,10 +185,12 @@ export function createRandomizedBiteSizedStockadesDefinition(
source: encounter.source ? { source: encounter.source ? {
...encounter.source, ...encounter.source,
files: ["src/game/dungeons/randomizedBiteSizedStockades.ts"], files: ["src/game/dungeons/randomizedBiteSizedStockades.ts"],
reviewedBehavior: "Every corridor slot and the final Stockades boss are deterministic rolls from the run seed.", reviewedBehavior: "Every corridor slot receives a distinct seeded creature and the final boss uses an independent seeded roll.",
adaptations: [ adaptations: [
...encounter.source.adaptations, ...encounter.source.adaptations,
"Each ordinary spawn slot independently selects a placed Stockades mob archetype from the immutable review catalog.", "The trash shuffle deals without replacement from the complete model-backed cross-dungeon mob catalog, so one run cannot repeat a mob identity.",
"The final boss is rolled independently from the complete model-backed cross-dungeon boss catalog while retaining the location's stable completion objective.",
"Randomized combat multipliers are normalized before party-level scaling so source-dungeon tuning cannot overwhelm a short leveling run.",
], ],
} : encounter.source, } : encounter.source,
})), })),
+90 -27
View File
@@ -3,20 +3,32 @@ import {
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID, STORMWIND_STOCKADE_DUNGEON_ID,
} from "./dungeonContentSource"; } from "./dungeonContentSource";
import { DUNGEON_REGISTRY, dungeonDefinitionById } from "./dungeonRegistry";
import { activateDungeonSession } from "./dungeonSession";
import { BITE_SIZED_STOCKADES_BOSS_CANDIDATES } from "./dungeons/biteSizedStockades";
import { import {
createRandomizedBiteSizedStockadesDefinition, DUNGEON_REGISTRY,
randomizedBiteSizedCreatureCatalog, RANDOMIZED_BITE_SIZED_CREATURE_CATALOG,
} from "./dungeons/randomizedBiteSizedStockades"; dungeonDefinitionById,
} from "./dungeonRegistry";
import { activateDungeonSession } from "./dungeonSession";
import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades";
import { createRandomizedBiteSizedStockadesDefinition } from "./dungeons/randomizedBiteSizedStockades";
const source = DUNGEON_REGISTRY[STORMWIND_STOCKADE_DUNGEON_ID]; const source = DUNGEON_REGISTRY[STORMWIND_STOCKADE_DUNGEON_ID];
const catalog = RANDOMIZED_BITE_SIZED_CREATURE_CATALOG;
function create(seed: string) {
return createRandomizedBiteSizedStockadesDefinition(source, seed, catalog);
}
function bossEntityServerEntry(definition: ReturnType<typeof create>): number | 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;
}
describe("Randomized Bite-Sized Stockades", () => { describe("Randomized Bite-Sized Stockades", () => {
it("keeps source geometry and both existing Stockades definitions immutable", () => { it("keeps source geometry and both existing Stockades definitions immutable", () => {
const originalSpawns = source.staticSpawns.map((spawn) => ({ ...spawn })); const originalSpawns = source.staticSpawns.map((spawn) => ({ ...spawn }));
const randomized = createRandomizedBiteSizedStockadesDefinition(source, "random-layout-a"); const randomized = create("random-layout-a");
expect(randomized.id).toBe(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID); expect(randomized.id).toBe(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
expect(randomized.navigationLinks).toEqual(source.navigationLinks); expect(randomized.navigationLinks).toEqual(source.navigationLinks);
@@ -26,34 +38,85 @@ describe("Randomized Bite-Sized Stockades", () => {
expect(source.staticSpawns).toEqual(originalSpawns); expect(source.staticSpawns).toEqual(originalSpawns);
}); });
it("rerolls every ordinary slot from the complete placed Stockades mob pool", () => { it("uses the complete model-backed cross-dungeon catalog regardless of installed packs", () => {
const catalog = randomizedBiteSizedCreatureCatalog(source); const mobs = catalog.filter((candidate) => candidate.kind === "mob");
const mobIds = catalog.filter((candidate) => candidate.kind === "mob").map((candidate) => candidate.entityId); const bosses = catalog.filter((candidate) => candidate.kind === "boss");
const observed = new Set<string>();
for (let index = 0; index < 2_000; index += 1) { expect(mobs.length).toBeGreaterThan(500);
const definition = createRandomizedBiteSizedStockadesDefinition(source, `mob-roll-${index}`); expect(bosses.length).toBeGreaterThan(100);
const bossId = definition.bosses[0].id; expect(new Set(catalog.map((candidate) => candidate.sourceDungeonId)).size).toBeGreaterThan(50);
for (const spawn of definition.staticSpawns.filter((candidate) => candidate.id !== bossId)) { expect(catalog.some((candidate) => candidate.definition.visual.model?.url.startsWith("/assets/creatures/wailing-caverns/"))).toBe(true);
expect(mobIds).toContain(spawn.entityId); expect(catalog.some((candidate) => candidate.definition.visual.model?.url.startsWith("/assets/game/creatures/shared/"))).toBe(true);
observed.add(spawn.entityId); 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([...observed].sort()).toEqual([...mobIds].sort()); expect(catalog.every((candidate) => /^(wow|rom):(mob|boss):[1-9][0-9]*$/.test(candidate.key))).toBe(true);
}); });
it("reconstructs the same party lineup from a run id and can select every boss", () => { it("replaces every corridor spawn and deals distinct mobs without replacement", () => {
const first = createRandomizedBiteSizedStockadesDefinition(source, "shared-run"); const randomized = create("every-slot-is-random");
const second = createRandomizedBiteSizedStockadesDefinition(source, "shared-run"); const bossId = randomized.bosses[0].id;
const trash = randomized.staticSpawns.filter((spawn) => spawn.id !== bossId);
const selectedIds = trash.map((spawn) => spawn.entityId);
const base = createBiteSizedStockadesDefinition(source, "every-slot-is-random:objective-slot");
expect(trash).toHaveLength(12);
expect(new Set(selectedIds).size).toBe(trash.length);
expect(selectedIds.every((entityId) => /^randomized:(wow|rom):mob:/.test(entityId))).toBe(true);
for (const spawn of trash) {
const entity = randomized.entities[spawn.entityId];
const originalSpawn = base.staticSpawns.find((candidate) => candidate.id === spawn.id)!;
const original = base.entities[originalSpawn.entityId];
expect(entity.kind).toBe("mob");
expect(entity.combat?.serverEntry).not.toBe(original.combat?.serverEntry);
expect(entity.combat).toMatchObject({
healthMultiplier: 1,
damageMultiplier: 1,
});
}
});
it("uses independent deterministic trash and boss rolls across run ids", () => {
const first = create("shared-run");
const second = create("shared-run");
expect(second.staticSpawns).toEqual(first.staticSpawns); expect(second.staticSpawns).toEqual(first.staticSpawns);
expect(second.bosses).toEqual(first.bosses); expect(second.bosses).toEqual(first.bosses);
const bosses = new Set<string>(); const bossEntries = new Set(catalog.filter((candidate) => candidate.kind === "boss").map((candidate) => candidate.serverEntry));
for (let index = 0; index < 1_000; index += 1) { const observedBossEntries = new Set<number>();
bosses.add(createRandomizedBiteSizedStockadesDefinition(source, `boss-roll-${index}`).bosses[0].id); 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 bossId = definition.bosses[0].id;
observedTrashLineups.add(definition.staticSpawns
.filter((spawn) => spawn.id !== bossId)
.map((spawn) => spawn.entityId)
.join("|"));
} }
expect([...bosses].sort()).toEqual(BITE_SIZED_STOCKADES_BOSS_CANDIDATES.map((candidate) => candidate.sourceSpawnId).sort()); expect([...observedBossEntries].every((entry) => bossEntries.has(entry))).toBe(true);
expect(observedBossEntries.size).toBeGreaterThan(100);
expect(observedTrashLineups.size).toBeGreaterThan(1_900);
expect(activateDungeonSession(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "shared-run" })).toBe(true); expect(activateDungeonSession(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, undefined, { runId: "shared-run" })).toBe(true);
expect(dungeonDefinitionById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)?.staticSpawns).toEqual(first.staticSpawns); expect(dungeonDefinitionById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID)?.staticSpawns).toEqual(first.staticSpawns);
}); });
it("keeps the randomized boss on the location's stable completion objective", () => {
const randomized = create("random-boss-objective");
const boss = randomized.bosses[0];
const spawn = randomized.staticSpawns.find((candidate) => candidate.id === boss.id)!;
const entity = randomized.entities[spawn.entityId];
const base = createBiteSizedStockadesDefinition(source, "random-boss-objective:objective-slot");
const originalSpawn = base.staticSpawns.find((candidate) => candidate.id === boss.id)!;
const originalBoss = base.entities[originalSpawn.entityId];
expect(entity.kind).toBe("boss");
expect(boss.name).toBe(entity.name);
expect(catalog.some((candidate) => candidate.kind === "boss" && candidate.entityId === spawn.entityId)).toBe(true);
expect(entity.combat?.serverEntry).not.toBe(originalBoss.combat?.serverEntry);
expect(randomized.encounters?.[0].rules[0].trigger).toEqual({ kind: "entities-dead", entityIds: [boss.id] });
expect(entity.combat).toMatchObject({ healthMultiplier: 1, damageMultiplier: 1 });
});
}); });
+50 -33
View File
@@ -2207,11 +2207,18 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
/* Main menu */ /* Main menu */
.main-menu-screen { .main-menu-screen {
--main-menu-gold: #d2b16d;
--main-menu-ice: #9fd9df;
display: grid; display: grid;
grid-template-columns: minmax(245px, 32%) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(285px, 31%);
grid-template-rows: 54px minmax(0, 1fr) 25px; grid-template-rows: 58px minmax(0, 1fr) 25px;
column-gap: 22px; gap: 12px;
padding-bottom: max(8px, env(safe-area-inset-bottom)); padding-bottom: max(10px, env(safe-area-inset-bottom));
background:
linear-gradient(90deg, rgba(2,7,12,.78), rgba(2,7,12,.38) 58%, rgba(2,7,12,.83)),
linear-gradient(180deg, rgba(2,7,12,.38), rgba(2,7,12,.91)),
url("./assets/ui/login/frozen-gateway.webp") center 44% / cover no-repeat,
#071019;
} }
.main-menu-header, .dungeon-select-header, .manastorm-select-header { .main-menu-header, .dungeon-select-header, .manastorm-select-header {
grid-column: 1 / 3; grid-column: 1 / 3;
@@ -2224,35 +2231,47 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
.main-menu-header > div:nth-child(2) span, .dungeon-select-header > div:nth-child(2) span, .manastorm-select-header > div:nth-child(2) span { color: var(--front-dim); font-size: 8px; letter-spacing: .13em; text-transform: uppercase; } .main-menu-header > div:nth-child(2) span, .dungeon-select-header > div:nth-child(2) span, .manastorm-select-header > div:nth-child(2) span { color: var(--front-dim); font-size: 8px; letter-spacing: .13em; text-transform: uppercase; }
.main-menu-header > div:nth-child(2) strong, .dungeon-select-header > div:nth-child(2) strong, .manastorm-select-header > div:nth-child(2) strong { overflow: hidden; font: 18px Georgia, serif; text-overflow: ellipsis; white-space: nowrap; } .main-menu-header > div:nth-child(2) strong, .dungeon-select-header > div:nth-child(2) strong, .manastorm-select-header > div:nth-child(2) strong { overflow: hidden; font: 18px Georgia, serif; text-overflow: ellipsis; white-space: nowrap; }
.main-menu-header > button { .main-menu-header > button {
margin-left: auto;
padding: 7px 10px; padding: 7px 10px;
border: 1px solid var(--front-line); border: 1px solid rgba(210,177,109,.3);
border-radius: 7px; border-radius: 4px;
background: rgba(255,255,255,.025); background: rgba(8,17,21,.74);
color: var(--front-dim); color: #d8d2c3;
cursor: pointer; cursor: pointer;
font-size: 9px; font-size: 9px;
} }
.main-menu-character { .main-menu-header { padding: 0 4px 5px; border-bottom-color: rgba(210,177,109,.34); }
grid-column: 1; .main-menu-header .front-brand > span:last-child { justify-content: center; }
.main-menu-sidebar {
grid-column: 2;
grid-row: 2; grid-row: 2;
display: grid;
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
grid-template-rows: minmax(0, 1fr) auto; padding-right: 2px;
border-right: 1px solid var(--front-line); overflow-y: auto;
} }
.main-menu-character__preview { min-width: 0; min-height: 0; } .main-menu-character {
.main-menu-character__preview .character-preview__viewport { bottom: 0; } display: grid;
.main-menu-character__preview .character-preview figcaption { display: none; } grid-template-columns: 54px minmax(0, 1fr);
.main-menu-character__summary { padding: 11px 18px 18px 2px; border-top: 1px solid rgba(204,224,180,.1); } align-items: center;
.main-menu-character__summary h1 { overflow: hidden; margin: 4px 0 2px; font: 27px Georgia, serif; text-overflow: ellipsis; white-space: nowrap; } gap: 12px;
padding: 14px;
border: 1px solid rgba(210,177,109,.4);
border-radius: 4px;
background: linear-gradient(145deg, rgba(27,38,41,.96), rgba(6,14,18,.96));
box-shadow: 0 16px 36px rgba(0,0,0,.34), inset 3px 0 0 rgba(210,177,109,.68);
}
.main-menu-character__icon { display: block; width: 52px; height: 52px; overflow: hidden; border: 1px solid; border-radius: 7px; background: #071015; box-shadow: 0 3px 12px rgba(0,0,0,.52); }
.main-menu-character__icon img { width: 100%; height: 100%; object-fit: cover; }
.main-menu-character__summary { min-width: 0; }
.main-menu-character__summary h1 { overflow: hidden; margin: 2px 0 3px; color: #f4eddd; font: 26px Georgia, serif; line-height: 1; text-overflow: ellipsis; white-space: nowrap; }
.main-menu-character__summary > strong, .main-menu-character__summary > small { display: block; } .main-menu-character__summary > strong, .main-menu-character__summary > small { display: block; }
.main-menu-character__summary > strong { font-size: 10px; } .main-menu-character__summary > strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.main-menu-character__summary > small { margin-top: 4px; color: var(--front-dim); font-size: 8px; text-transform: capitalize; } .main-menu-character__summary > small { color: #9fb2b0; font-size: 8px; letter-spacing: .12em; text-transform: uppercase; }
.main-menu-modes { grid-column: 2; grid-row: 2; min-width: 0; min-height: 0; padding: 18px 0 12px; overflow: auto; } .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-modes > header h2 { margin: 3px 0 4px; font: 28px Georgia, serif; font-weight: 400; } .main-menu-sidebar > .online-group-panel > summary { min-height: 46px; }
.main-menu-modes > header > p:last-child { max-width: 570px; margin: 0; color: var(--front-dim); font-size: 9px; line-height: 1.45; } .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: 14px; } .main-menu-mode-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 0; }
.main-menu-gm-stack { display: grid; gap: 8px; } .main-menu-gm-stack { display: grid; gap: 8px; }
.main-menu-mode { .main-menu-mode {
position: relative; position: relative;
@@ -2821,7 +2840,7 @@ html[data-display-surface="bottom"] .single-context-toggle { display: none; }
.roster-header { grid-column: 1 / 3; } .roster-header { grid-column: 1 / 3; }
.character-select-screen > .controller-legend { grid-column: 1 / 3; } .character-select-screen > .controller-legend { grid-column: 1 / 3; }
.character-create-screen { grid-template-columns: 235px minmax(220px, 1fr) 270px; } .character-create-screen { grid-template-columns: 235px minmax(220px, 1fr) 270px; }
.main-menu-screen { grid-template-columns: minmax(225px, 31%) minmax(0, 1fr); column-gap: 16px; } .main-menu-screen { grid-template-columns: minmax(0, 1fr) 280px; column-gap: 16px; }
.main-menu-mode { min-height: 72px; } .main-menu-mode { min-height: 72px; }
.dungeon-select-screen { grid-template-columns: 255px minmax(0, 1fr); column-gap: 14px; } .dungeon-select-screen { grid-template-columns: 255px minmax(0, 1fr); column-gap: 14px; }
.dungeon-select-detail { grid-template-columns: minmax(190px, .82fr) minmax(220px, 1.18fr); gap: 13px; } .dungeon-select-detail { grid-template-columns: minmax(190px, .82fr) minmax(220px, 1.18fr); gap: 13px; }
@@ -2856,18 +2875,15 @@ html[data-display-surface="bottom"] .single-context-toggle { display: none; }
.roster-footer__status { display: none; } .roster-footer__status { display: none; }
.roster-enter { width: min(280px, 62%); margin-left: auto; } .roster-enter { width: min(280px, 62%); margin-left: auto; }
.character-avatar { transform: perspective(600px) rotateY(var(--avatar-turn)) scale(.78); } .character-avatar { transform: perspective(600px) rotateY(var(--avatar-turn)) scale(.78); }
.main-menu-screen { grid-template-columns: 1fr; grid-template-rows: 48px 86px minmax(0, 1fr); gap: 0; } .main-menu-screen { grid-template-columns: 1fr; grid-template-rows: 48px auto minmax(0, 1fr); gap: 8px; }
.main-menu-header { grid-column: 1; grid-row: 1; } .main-menu-header { grid-column: 1; grid-row: 1; }
.main-menu-header > div:nth-child(2) strong { font-size: 14px; }
.main-menu-header > button { padding: 6px 8px; } .main-menu-header > button { padding: 6px 8px; }
.main-menu-character { grid-column: 1; grid-row: 2; display: grid; grid-template-columns: 86px minmax(0,1fr); grid-template-rows: 1fr; border-right: 0; border-bottom: 1px solid var(--front-line); } .main-menu-sidebar { grid-column: 1; grid-row: 2; display: grid; max-height: 176px; grid-template-columns: minmax(190px, .8fr) minmax(0, 1.2fr); gap: 8px; overflow-y: auto; }
.main-menu-character__preview { grid-column: 1; } .main-menu-sidebar > .online-group-panel { margin: 0; }
.main-menu-character__preview .character-preview__aura { width: 75px; } .main-menu-character { align-self: start; }
.main-menu-character__summary { grid-column: 2; align-self: center; padding: 7px 8px; border-top: 0; }
.main-menu-character__summary h1 { font-size: 20px; } .main-menu-character__summary h1 { font-size: 20px; }
.main-menu-modes { grid-column: 1; grid-row: 3; padding-top: 10px; } .main-menu-modes { grid-column: 1; grid-row: 3; padding-top: 10px; }
.main-menu-modes > header h2 { font-size: 22px; } .main-menu-mode-grid { margin-top: 0; }
.main-menu-mode-grid { margin-top: 9px; }
.main-menu-mode { min-height: 64px; } .main-menu-mode { min-height: 64px; }
.main-menu-mode > span small { display: none; } .main-menu-mode > span small { display: none; }
.dungeon-select-screen { grid-template-columns: 40% minmax(0, 60%); grid-template-rows: 48px minmax(0,1fr); column-gap: 10px; } .dungeon-select-screen { grid-template-columns: 40% minmax(0, 60%); grid-template-rows: 48px minmax(0,1fr); column-gap: 10px; }
@@ -2910,6 +2926,7 @@ html[data-display-surface="bottom"] .single-context-toggle { display: none; }
.manastorm-select-header .front-brand small, .manastorm-select-header .front-brand strong { display: none; } .manastorm-select-header .front-brand small, .manastorm-select-header .front-brand strong { display: none; }
.main-menu-header .front-brand, .dungeon-select-header .front-brand, .manastorm-select-header .front-brand { gap: 0; } .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-mode-grid { grid-template-columns: 1fr; }
.main-menu-sidebar { max-height: 210px; grid-template-columns: 1fr; }
.main-menu-mode { min-height: 58px; } .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-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; } .dungeon-select-header { grid-column: 1; grid-row: 1; }
+2 -2
View File
@@ -14,11 +14,11 @@ export function FrontSurface({ children, className = "", bottom = false, ariaLab
); );
} }
export function BrandMark({ compact = false }: { compact?: boolean }) { export function BrandMark({ compact = false, tagline = "Answer the call" }: { compact?: boolean; tagline?: string | null }) {
return ( return (
<div className={`front-brand ${compact ? "front-brand--compact" : ""}`}> <div className={`front-brand ${compact ? "front-brand--compact" : ""}`}>
<span className="brand-sigil" aria-hidden="true">+</span> <span className="brand-sigil" aria-hidden="true">+</span>
<span><small>Answer the call</small><strong>Healer Man</strong></span> <span>{tagline && <small>{tagline}</small>}<strong>Healer Man</strong></span>
</div> </div>
); );
} }
+22 -28
View File
@@ -1,9 +1,9 @@
import { lazy, Suspense, useMemo, useState } from "react"; import { lazy, Suspense, useMemo, useState } from "react";
import { classById, FACTIONS, raceById } from "../app/characterCatalog"; import { classById } from "../app/characterCatalog";
import { selectedCharacter, useShellStore } from "../app/shellStore"; import { selectedCharacter, useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame"; import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { specializationById, specializationIconUrl } from "../game/specializationCatalog";
import { useMenuController, type MenuAction } from "../input/useMenuController"; import { useMenuController, type MenuAction } from "../input/useMenuController";
import { CharacterPreview } from "./CharacterPreview";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel"; import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
import { ControllerButton } from "./ControllerButton"; import { ControllerButton } from "./ControllerButton";
import { ContentManagerScreen } from "./ContentManagerScreen"; import { ContentManagerScreen } from "./ContentManagerScreen";
@@ -170,8 +170,9 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
onBack: returnToCharacters, onBack: returnToCharacters,
}); });
const race = character ? raceById(character.raceId) : null;
const characterClass = character ? classById(character.classId) : null; const characterClass = character ? classById(character.classId) : null;
const specialization = specializationById(character?.specializationId);
const secondaryClass = character?.secondaryClassId ? classById(character.secondaryClassId) : null;
return ( return (
<DualDisplayFrame <DualDisplayFrame
@@ -179,11 +180,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
top={ top={
<FrontSurface className="main-menu-screen" ariaLabel="Healer Man main menu"> <FrontSurface className="main-menu-screen" ariaLabel="Healer Man main menu">
<header className="main-menu-header"> <header className="main-menu-header">
<BrandMark compact /> <BrandMark compact tagline={null} />
<div>
<span>Adventurer selected</span>
<strong>Choose your next journey</strong>
</div>
<ControllerButton <ControllerButton
controlId="back-to-roster" controlId="back-to-roster"
selectedId={controller.selectedId} selectedId={controller.selectedId}
@@ -195,30 +192,27 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
</ControllerButton> </ControllerButton>
</header> </header>
<aside className="main-menu-character" aria-label="Selected character"> <aside className="main-menu-sidebar" aria-label="Character and online group">
<div className="main-menu-character__preview"> {character && characterClass && (
<CharacterPreview character={character} /> <section className="main-menu-character" aria-label="Selected character">
</div> <span className="main-menu-character__icon" style={{ borderColor: characterClass.color }}>
{character && race && characterClass && ( <img src={specializationIconUrl(character.classId, character.specializationId)} alt="" aria-hidden="true" />
<div className="main-menu-character__summary"> </span>
<p className="front-kicker">Ready for adventure</p> <div className="main-menu-character__summary">
<h1>{character.name}</h1> <small>Level {character.level}</small>
<strong style={{ color: characterClass.color }}> <h1>{character.name}</h1>
Level {character.level} {race.name} {characterClass.name} <strong style={{ color: characterClass.color }}>
</strong> {characterClass.name}{isRom
<small>{FACTIONS[race.faction].name} / {character.gender}</small> ? secondaryClass ? ` / ${secondaryClass.name}` : " / No secondary class"
{isRom && <small>RoM / {character.secondaryClassId ? `${characterClass.name} + ${classById(character.secondaryClassId).name}` : "Secondary class not chosen"}</small>} : specialization ? ` / ${specialization.displayName}` : ""}
</div> </strong>
</div>
</section>
)} )}
<OnlineGroupPanel defaultOpen />
</aside> </aside>
<main className="main-menu-modes"> <main className="main-menu-modes">
<header>
<p className="front-kicker">Main menu</p>
<h2>Ways to play</h2>
<p>Select a destination. More adventures will unlock here as they are built.</p>
</header>
<OnlineGroupPanel />
<nav className="main-menu-mode-grid" aria-label="Main menu destinations"> <nav className="main-menu-mode-grid" aria-label="Main menu destinations">
<ControllerButton controlId="mode-companion-readiness" selectedId={controller.selectedId} select={controller.select} <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}> className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenReadiness}>
+5 -2
View File
@@ -9,9 +9,12 @@ import {
type PartyRole, type PartyRole,
} from "../game/partyRoles"; } from "../game/partyRoles";
export function OnlineGroupPanel({ forceAiFill = false }: { readonly forceAiFill?: boolean }) { export function OnlineGroupPanel({ forceAiFill = false, defaultOpen = false }: {
readonly forceAiFill?: boolean;
readonly defaultOpen?: boolean;
}) {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(defaultOpen);
const session = useShellStore((state) => state.session); const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter); const character = useShellStore(selectedCharacter);
const snapshot = useOnlineGroupStore((state) => state.snapshot); const snapshot = useOnlineGroupStore((state) => state.snapshot);
+22 -13
View File
@@ -8,22 +8,21 @@ import { dungeonContentIsUsable, installContentPack } from "../content/contentMa
import { useContentManagerSnapshot } from "../content/useContentManager"; import { useContentManagerSnapshot } from "../content/useContentManager";
import { import {
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
STORMWIND_STOCKADE_DUNGEON_ID,
dungeonContentPackId, dungeonContentPackId,
} from "../game/dungeonContentSource"; } from "../game/dungeonContentSource";
import { randomizedBiteSizedCreatureCatalog } from "../game/dungeons/randomizedBiteSizedStockades";
import { dungeonById } from "../game/dungeonCatalog"; import { dungeonById } from "../game/dungeonCatalog";
import { requireDungeonDefinition } from "../game/dungeonRegistry"; import { RANDOMIZED_BITE_SIZED_CREATURE_CATALOG, requireDungeonDefinition } from "../game/dungeonRegistry";
import { BrandMark, FrontSurface } from "./FrontSurface"; import { BrandMark, FrontSurface } from "./FrontSurface";
import { DungeonDifficultySlider } from "./DungeonDifficultySlider"; import { DungeonDifficultySlider } from "./DungeonDifficultySlider";
import { OnlineGroupPanel } from "./OnlineGroupPanel"; import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { CompanionPoolControls } from "./CompanionPoolControls"; import { CompanionPoolControls } from "./CompanionPoolControls";
import { CompanionReadinessScreen } from "./CompanionReadinessScreen"; import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
import { ensureRandomizedBiteSizedRunContent } from "../app/randomizedBiteSizedContent";
installDungeonShellRuntime(); installDungeonShellRuntime();
const DUNGEON = dungeonById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID); const DUNGEON = dungeonById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
const CANDIDATES = randomizedBiteSizedCreatureCatalog(requireDungeonDefinition(STORMWIND_STOCKADE_DUNGEON_ID)); const CANDIDATES = RANDOMIZED_BITE_SIZED_CREATURE_CATALOG;
export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () => void }) { export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () => void }) {
useContentManagerSnapshot(); useContentManagerSnapshot();
@@ -51,11 +50,21 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
const launch = async (role: PartyRole) => { const launch = async (role: PartyRole) => {
if (!character || !DUNGEON?.available) return; if (!character || !DUNGEON?.available) return;
if (!onlineGroup || !session || session.kind !== "account") { if (!onlineGroup || !session || session.kind !== "account") {
enterDungeon(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, role, { const runId = globalThis.crypto.randomUUID();
difficultyId: requireDungeonDefinition(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID).defaultDifficultyId, setDownloading(true); setError("");
bossDifficulty, try {
freshRun: true, await ensureRandomizedBiteSizedRunContent(runId);
}); enterDungeon(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID, role, {
difficultyId: requireDungeonDefinition(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID).defaultDifficultyId,
bossDifficulty,
freshRun: true,
runId,
});
} catch (caught) {
setError(caught instanceof Error ? caught.message : "The randomized creature download failed.");
} finally {
setDownloading(false);
}
return; return;
} }
if (!await updateOnlineMember(session, character, role)) return; if (!await updateOnlineMember(session, character, role)) return;
@@ -99,9 +108,9 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
</header> </header>
<main className="randomized-bite-sized-screen__layout"> <main className="randomized-bite-sized-screen__layout">
<section className="randomized-bite-sized-screen__hero"> <section className="randomized-bite-sized-screen__hero">
<p className="front-kicker">A different cellblock every run</p> <p className="front-kicker">A different lineup every run</p>
<h1>Randomized Bite-Sized</h1> <h1>Randomized Bite-Sized</h1>
<p>Fight through the short Stockades route with every ordinary enemy slot rerolled, then face one randomly selected warden.</p> <p>The mode picks an eligible short layout—currently Cellblock Rush—deals a different cross-dungeon creature into every enemy slot, then rolls the boss separately.</p>
<div className="randomized-bite-sized-screen__odds"> <div className="randomized-bite-sized-screen__odds">
<div><strong>{mobCount}</strong><span>mob types in the shuffle</span></div> <div><strong>{mobCount}</strong><span>mob types in the shuffle</span></div>
<div><strong>{bossCount}</strong><span>possible final bosses</span></div> <div><strong>{bossCount}</strong><span>possible final bosses</span></div>
@@ -116,11 +125,11 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
<h2>{DUNGEON?.title ?? "Cellblock Shuffle"}</h2> <h2>{DUNGEON?.title ?? "Cellblock Shuffle"}</h2>
<DungeonDifficultySlider value={bossDifficulty} onChange={setBossDifficulty} selectedId="" select={() => undefined} /> <DungeonDifficultySlider value={bossDifficulty} onChange={setBossDifficulty} selectedId="" select={() => undefined} />
{!installed ? ( {!installed ? (
<button className="front-button front-button--primary" type="button" disabled={downloading} onClick={() => void install()}>{downloading ? "Installing…" : "Install Stockades content"}</button> <button className="front-button front-button--primary" type="button" disabled={downloading} onClick={() => void install()}>{downloading ? "Installing…" : "Install randomized dungeon content"}</button>
) : roles.length ? ( ) : roles.length ? (
<div className="randomized-bite-sized-screen__roles"> <div className="randomized-bite-sized-screen__roles">
<strong>Enter as</strong> <strong>Enter as</strong>
{roles.map((role) => <button className="front-button front-button--primary" key={role} type="button" disabled={!DUNGEON?.available} onClick={() => void launch(role)}>{PARTY_ROLE_LABELS[role]}</button>)} {roles.map((role) => <button className="front-button front-button--primary" key={role} type="button" disabled={!DUNGEON?.available || downloading} onClick={() => void launch(role)}>{downloading ? "Preparing run…" : PARTY_ROLE_LABELS[role]}</button>)}
</div> </div>
) : <p className="front-notice">Choose a specialization in your Class Hall before entering.</p>} ) : <p className="front-notice">Choose a specialization in your Class Hall before entering.</p>}
{!DUNGEON?.available && <p className="front-notice">This randomized route is not enterable in the current content build.</p>} {!DUNGEON?.available && <p className="front-notice">This randomized route is not enterable in the current content build.</p>}
+3 -9
View File
@@ -6,17 +6,11 @@ import {
useRandomizedCreatureReadiness, useRandomizedCreatureReadiness,
} from "../app/randomizedCreatureReadiness"; } from "../app/randomizedCreatureReadiness";
import { useShellStore } from "../app/shellStore"; import { useShellStore } from "../app/shellStore";
import { STORMWIND_STOCKADE_DUNGEON_ID } from "../game/dungeonContentSource"; import type { RandomizedCreatureKind } from "../game/dungeons/randomizedBiteSizedStockades";
import { import { RANDOMIZED_BITE_SIZED_CREATURE_CATALOG } from "../game/dungeonRegistry";
randomizedBiteSizedCreatureCatalog,
type RandomizedCreatureKind,
} from "../game/dungeons/randomizedBiteSizedStockades";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { BrandMark, FrontSurface } from "./FrontSurface"; import { BrandMark, FrontSurface } from "./FrontSurface";
const CREATURES = randomizedBiteSizedCreatureCatalog( const CREATURES = RANDOMIZED_BITE_SIZED_CREATURE_CATALOG;
requireDungeonDefinition(STORMWIND_STOCKADE_DUNGEON_ID),
);
type KindFilter = "all" | RandomizedCreatureKind; type KindFilter = "all" | RandomizedCreatureKind;
type StatusFilter = "all" | CreatureReadinessStatus; type StatusFilter = "all" | CreatureReadinessStatus;
+7
View File
@@ -125,6 +125,13 @@
.roster-character-info { width: min(215px, 42%); padding: 13px; } .roster-character-info { width: min(215px, 42%); padding: 13px; }
.roster-character-info h1 { font-size: 30px; } .roster-character-info h1 { font-size: 30px; }
.roster-footer { padding-top: 6px; } .roster-footer { padding-top: 6px; }
.main-menu-screen { grid-template-columns: minmax(0, 1fr) 285px; grid-template-rows: 50px minmax(0, 1fr) 24px; gap: 8px; }
.main-menu-sidebar { grid-column: 2; grid-row: 2; display: block; max-height: none; overflow-y: auto; }
.main-menu-character { grid-template-columns: 48px minmax(0, 1fr); gap: 9px; padding: 10px; }
.main-menu-character__icon { width: 46px; height: 46px; }
.main-menu-character__summary h1 { font-size: 21px; }
.main-menu-modes { grid-column: 1; grid-row: 2; padding: 0 2px 8px 0; }
.main-menu-mode-grid { margin-top: 0; }
.character-create-screen { grid-template-columns: 225px minmax(220px, 1fr) 260px; grid-template-rows: 50px 3px minmax(0, 1fr) 60px; } .character-create-screen { grid-template-columns: 225px minmax(220px, 1fr) 260px; grid-template-rows: 50px 3px minmax(0, 1fr) 60px; }
.creator-header, .creator-progress, .creator-footer { grid-column: 1 / 4; } .creator-header, .creator-progress, .creator-footer { grid-column: 1 / 4; }
.creator-races { grid-column: 1; } .creator-races { grid-column: 1; }