forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
3e30af4c98
commit
97ee0c13f9
25 files changed
+559
-162
No files matched your search
@@ -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
|
||||
members with intact bodies and starter weapon silhouettes. Evidence is in
|
||||
`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.
|
||||
@@ -25,6 +25,12 @@ export const CONTENT_SCHEMA_VERSION = 1;
|
||||
export const DEFAULT_MINIMUM_APP_VERSION = "0.1.0";
|
||||
export const WOW_CAMPAIGN_DUNGEON_COUNT = 54;
|
||||
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([
|
||||
".avif", ".glb", ".js", ".json", ".ktx2", ".mp3", ".ogg", ".png",
|
||||
@@ -435,6 +441,7 @@ export async function publishContent(options = {}) {
|
||||
: [];
|
||||
const packDefinitions = [
|
||||
...campaignPackDefinitions,
|
||||
...(campaignDefinitions ? [RANDOMIZED_BITE_SIZED_PACK_DEFINITION] : []),
|
||||
...(options.packDefinitions ?? []),
|
||||
];
|
||||
if (campaignDefinitions) assertCampaignDungeonManifest(createContentManifest(files, { ...options, packDefinitions }), campaignDefinitions);
|
||||
|
||||
@@ -6,6 +6,7 @@ import test from "node:test";
|
||||
import {
|
||||
BUNDLED_ANDROID_PACKS,
|
||||
PROJECT_ROOT,
|
||||
RANDOMIZED_BITE_SIZED_PACK_DEFINITION,
|
||||
assertCampaignDungeonManifest,
|
||||
contentPackForPath,
|
||||
createContentManifest,
|
||||
@@ -14,6 +15,15 @@ import {
|
||||
scanContentSources,
|
||||
} 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", () => {
|
||||
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");
|
||||
|
||||
@@ -104,7 +104,7 @@ export function createDungeonReportRepository(database, { isGameMaster, ErrorTyp
|
||||
saveRandomizedCreatureReadiness(account, input) {
|
||||
if (!isGameMaster(account)) throw new ErrorType("Game Master access is required.", 403, "gm_required");
|
||||
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.creatureName.trim() || input.creatureName.length > 128
|
||||
|| !["mob", "boss"].includes(input.creatureKind)) {
|
||||
|
||||
@@ -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 player = (await database.register("ReadinessPlayer", "readiness-player")).account;
|
||||
const creature = {
|
||||
creatureKey: "stormwind-stockade:entity-1711",
|
||||
creatureKey: "wow:mob:3868",
|
||||
dungeonId: "randomized-bite-sized-stormwind-stockade",
|
||||
creatureName: "Defias Convict",
|
||||
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();
|
||||
assert.equal(labels.reviews[0].status, "ready");
|
||||
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" };
|
||||
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) });
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"core:arenas"
|
||||
],
|
||||
"hiddenContentPackPrefixes": [
|
||||
"manastorm:"
|
||||
"manastorm:",
|
||||
"activity:"
|
||||
],
|
||||
"maxBootstrapStoredBytes": 83886080
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const MANASTORM_ACTIVITY: OnlineGroupActivity = {
|
||||
|
||||
function entryDependencies(enterDungeon: () => boolean) {
|
||||
return {
|
||||
prepareRandomizedDungeonContent: vi.fn(async () => undefined),
|
||||
installDungeonRuntime: vi.fn(async () => undefined),
|
||||
enterDungeon: vi.fn(enterDungeon),
|
||||
installManastormRuntime: vi.fn(async () => undefined),
|
||||
@@ -48,6 +49,20 @@ describe("online group activity entry", () => {
|
||||
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 () => {
|
||||
const consumeActivity = vi.fn();
|
||||
const dependencies = entryDependencies(() => false);
|
||||
|
||||
@@ -12,11 +12,13 @@ import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
import { SHELVED_MODES_ENABLED } from "./releaseFeatures";
|
||||
import type { DungeonEntrySettings } from "./dungeonShellRuntimeBridge";
|
||||
import { installShelvedOnlineGroupRuntime } from "@shelvedOnlineGroupRuntime";
|
||||
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../game/dungeonContentSource";
|
||||
|
||||
const GROUP_POLL_MS = 2_500;
|
||||
const ACTIVITY_JOIN_WINDOW_MS = 10 * 60 * 1000;
|
||||
|
||||
interface OnlineGroupActivityEntryDependencies {
|
||||
readonly prepareRandomizedDungeonContent: (seed: string) => Promise<void>;
|
||||
readonly installDungeonRuntime: () => Promise<void>;
|
||||
readonly enterDungeon: (dungeonId: string, role?: PartyRole, settings?: DungeonEntrySettings) => boolean;
|
||||
readonly installManastormRuntime: () => Promise<void>;
|
||||
@@ -24,6 +26,10 @@ interface OnlineGroupActivityEntryDependencies {
|
||||
}
|
||||
|
||||
const DEFAULT_ACTIVITY_ENTRY_DEPENDENCIES: OnlineGroupActivityEntryDependencies = {
|
||||
prepareRandomizedDungeonContent: async (seed) => {
|
||||
const { ensureRandomizedBiteSizedRunContent } = await import("./randomizedBiteSizedContent");
|
||||
await ensureRandomizedBiteSizedRunContent(seed);
|
||||
},
|
||||
installDungeonRuntime: async () => {
|
||||
const { installDungeonShellRuntime } = await import("./dungeonShellRuntime");
|
||||
installDungeonShellRuntime();
|
||||
@@ -47,6 +53,9 @@ export async function enterOnlineGroupActivity(
|
||||
): Promise<boolean> {
|
||||
let entered: boolean;
|
||||
if (activity.type === "dungeon") {
|
||||
if (activity.contentId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) {
|
||||
await dependencies.prepareRandomizedDungeonContent(activity.id);
|
||||
}
|
||||
await dependencies.installDungeonRuntime();
|
||||
entered = dependencies.enterDungeon(activity.contentId, role, {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Capacitor } from "@capacitor/core";
|
||||
import {
|
||||
appVersionMeetsMinimum,
|
||||
changedFilesForPack,
|
||||
contentPackIdsForLogicalUrls,
|
||||
filesForContentPack,
|
||||
parseContentManifest,
|
||||
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> {
|
||||
if (contentPackIsBundled(packId)) return;
|
||||
const remainingFiles = { ...installedState.files };
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
appVersionMeetsMinimum,
|
||||
changedFilesForPack,
|
||||
contentPackIdsForLogicalUrls,
|
||||
filesForContentPack,
|
||||
parseContentManifest,
|
||||
type ContentManifest,
|
||||
@@ -46,4 +47,28 @@ describe("content manifest", () => {
|
||||
"/assets/a.glb": { sha256: "a".repeat(64) },
|
||||
}).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);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,26 @@ export interface ContentManifest {
|
||||
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> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("derived dungeon content sources", () => {
|
||||
expect(dungeonContentSourceId(BITE_SIZED_STOCKADES_DUNGEON_ID)).toBe(STORMWIND_STOCKADE_DUNGEON_ID);
|
||||
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(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");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
export const STORMWIND_STOCKADE_DUNGEON_ID = "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_CONTENT_PACK_ID = "activity:randomized-bite-sized";
|
||||
|
||||
const DUNGEON_CONTENT_SOURCE_IDS: Readonly<Record<string, string>> = Object.freeze({
|
||||
[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 {
|
||||
if (dungeonId === RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID) {
|
||||
return RANDOMIZED_BITE_SIZED_CONTENT_PACK_ID;
|
||||
}
|
||||
return `dungeon:${dungeonContentSourceId(dungeonId)}`;
|
||||
}
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
dungeonContentSourceId,
|
||||
} from "./dungeonContentSource";
|
||||
import { createBiteSizedStockadesDefinition } from "./dungeons/biteSizedStockades";
|
||||
import { createRandomizedBiteSizedStockadesDefinition } from "./dungeons/randomizedBiteSizedStockades";
|
||||
import {
|
||||
createRandomizedBiteSizedStockadesDefinition,
|
||||
randomizedBiteSizedCreatureCatalog,
|
||||
} from "./dungeons/randomizedBiteSizedStockades";
|
||||
|
||||
export { DEFAULT_DUNGEON_ID } from "./dungeonDefaults";
|
||||
|
||||
@@ -201,8 +204,19 @@ const adaptedSourceRegistry = Object.fromEntries(adaptedSourceEntries) as Readon
|
||||
const stockadesDefinition = adaptedSourceRegistry[STORMWIND_STOCKADE_DUNGEON_ID];
|
||||
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 randomizedBiteSizedStockadesTemplate = createRandomizedBiteSizedStockadesDefinition(stockadesDefinition, "catalog-template");
|
||||
const randomizedBiteSizedStockadesTemplate = randomizedBiteSizedDefinitionForSeed("catalog-template");
|
||||
const dungeonRegistryEntries: readonly (readonly [string, DungeonDefinition])[] = [
|
||||
...adaptedSourceEntries,
|
||||
[biteSizedStockadesTemplate.id, biteSizedStockadesTemplate],
|
||||
@@ -244,7 +258,7 @@ export function prepareDungeonSessionDefinition(id: string, seed: string): Dunge
|
||||
}
|
||||
const definition = id === BITE_SIZED_STOCKADES_DUNGEON_ID
|
||||
? createBiteSizedStockadesDefinition(stockadesDefinition, seed)
|
||||
: createRandomizedBiteSizedStockadesDefinition(stockadesDefinition, seed);
|
||||
: randomizedBiteSizedDefinitionForSeed(seed);
|
||||
sessionDungeonDefinitions.set(id, definition);
|
||||
return definition;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import type { DungeonDefinition } from "../dungeonTypes";
|
||||
import type { PopulationEntityDefinition } from "../mobPopulation";
|
||||
import {
|
||||
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
STORMWIND_STOCKADE_DUNGEON_ID,
|
||||
} from "../dungeonContentSource";
|
||||
import {
|
||||
BITE_SIZED_STOCKADES_BOSS_CANDIDATES,
|
||||
createBiteSizedStockadesDefinition,
|
||||
} from "./biteSizedStockades";
|
||||
import { RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID } from "../dungeonContentSource";
|
||||
import { createBiteSizedStockadesDefinition } from "./biteSizedStockades";
|
||||
|
||||
export type RandomizedCreatureKind = "mob" | "boss";
|
||||
|
||||
@@ -18,7 +12,15 @@ export interface RandomizedBiteSizedCreature {
|
||||
readonly entityId: string;
|
||||
readonly name: string;
|
||||
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 {
|
||||
@@ -30,64 +32,133 @@ function seedHash(seed: string): number {
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function placedEntityIds(source: DungeonDefinition): ReadonlySet<string> {
|
||||
return new Set([
|
||||
...source.staticSpawns.map((spawn) => spawn.entityId),
|
||||
...source.roamingPacks.flatMap((pack) => pack.members.map((member) => member.entityId)),
|
||||
]);
|
||||
function seededRandom(seed: string): () => number {
|
||||
let state = seedHash(seed) || 0x6d2b79f5;
|
||||
return () => {
|
||||
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. */
|
||||
export function randomizedBiteSizedCreatureCatalog(
|
||||
source: DungeonDefinition,
|
||||
): readonly RandomizedBiteSizedCreature[] {
|
||||
if (source.id !== STORMWIND_STOCKADE_DUNGEON_ID) {
|
||||
throw new Error(`Randomized Bite-Sized Stockades requires ${STORMWIND_STOCKADE_DUNGEON_ID}.`);
|
||||
function seededShuffle<T>(values: readonly T[], seed: string): T[] {
|
||||
const shuffled = [...values];
|
||||
const random = seededRandom(seed);
|
||||
for (let index = shuffled.length - 1; index > 0; index -= 1) {
|
||||
const swapIndex = Math.floor(random() * (index + 1));
|
||||
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]];
|
||||
}
|
||||
const placed = placedEntityIds(source);
|
||||
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 shuffled;
|
||||
}
|
||||
|
||||
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}`,
|
||||
/**
|
||||
* The immutable cross-dungeon review catalog used by seeded generation and the
|
||||
* GM readiness screen. Installed state never limits this pool; selected model
|
||||
* 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: entity.combat?.serverEntry ?? null,
|
||||
serverEntry,
|
||||
definition: entity,
|
||||
} satisfies RandomizedBiteSizedCreature];
|
||||
}))
|
||||
.sort((left, right) => left.kind.localeCompare(right.kind) || left.name.localeCompare(right.name));
|
||||
.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 } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rerolls every ordinary corridor spawn from the complete placed Stockades mob
|
||||
* pool. The existing bite-sized builder remains the authority for geometry,
|
||||
* navigation, sealed wings, and the independently seeded five-boss roll.
|
||||
* 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(
|
||||
source: DungeonDefinition,
|
||||
seed: string,
|
||||
catalog: readonly RandomizedBiteSizedCreature[],
|
||||
): DungeonDefinition {
|
||||
const base = createBiteSizedStockadesDefinition(source, seed);
|
||||
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 base = createBiteSizedStockadesDefinition(source, `${seed}:objective-slot`);
|
||||
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) => {
|
||||
if (spawn.id === bossSpawnId) return spawn;
|
||||
const candidate = mobCandidates[seedHash(`${seed}:${spawn.id}`) % mobCandidates.length];
|
||||
return { ...spawn, entityId: candidate.entityId };
|
||||
const candidate = selectedMobs.get(spawn.id);
|
||||
return { ...spawn, entityId: candidate ? runtimeEntity(candidate).id : bossEntity.id };
|
||||
});
|
||||
const usedEntityIds = new Set(randomizedSpawns.map((spawn) => spawn.entityId));
|
||||
const entities = Object.fromEntries(
|
||||
Object.entries(source.entities).filter(([entityId]) => usedEntityIds.has(entityId)),
|
||||
) as Readonly<Record<string, PopulationEntityDefinition>>;
|
||||
const selectedCandidates = [...selectedMobs.values(), bossCandidate];
|
||||
const entities = Object.fromEntries(selectedCandidates.map((candidate) => {
|
||||
const entity = runtimeEntity(candidate);
|
||||
return [entity.id, entity];
|
||||
})) as Readonly<Record<string, PopulationEntityDefinition>>;
|
||||
const boss = { ...base.bosses[0], name: bossCandidate.name };
|
||||
|
||||
return {
|
||||
...base,
|
||||
@@ -97,11 +168,16 @@ export function createRandomizedBiteSizedStockadesDefinition(
|
||||
...base.presentation,
|
||||
location: "Stormwind Stockade · Shifting Cellblock",
|
||||
theme: "Randomized Bite-Sized",
|
||||
summary: "Every enemy slot and the final warden are rerolled for a fast, unpredictable one-boss run.",
|
||||
loadingMessage: "Shuffling prisoners, guards, and the final warden...",
|
||||
summary: "Every enemy slot is dealt a distinct cross-dungeon creature, followed by an independently randomized boss.",
|
||||
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,
|
||||
staticSpawns: randomizedSpawns,
|
||||
bosses: [boss],
|
||||
objectiveOrder: [boss],
|
||||
encounters: (base.encounters ?? []).map((encounter) => ({
|
||||
...encounter,
|
||||
id: `${RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID}:boss-progression`,
|
||||
@@ -109,10 +185,12 @@ export function createRandomizedBiteSizedStockadesDefinition(
|
||||
source: encounter.source ? {
|
||||
...encounter.source,
|
||||
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: [
|
||||
...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,
|
||||
})),
|
||||
|
||||
@@ -3,20 +3,32 @@ import {
|
||||
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
STORMWIND_STOCKADE_DUNGEON_ID,
|
||||
} from "./dungeonContentSource";
|
||||
import { DUNGEON_REGISTRY, dungeonDefinitionById } from "./dungeonRegistry";
|
||||
import { activateDungeonSession } from "./dungeonSession";
|
||||
import { BITE_SIZED_STOCKADES_BOSS_CANDIDATES } from "./dungeons/biteSizedStockades";
|
||||
import {
|
||||
createRandomizedBiteSizedStockadesDefinition,
|
||||
randomizedBiteSizedCreatureCatalog,
|
||||
} from "./dungeons/randomizedBiteSizedStockades";
|
||||
DUNGEON_REGISTRY,
|
||||
RANDOMIZED_BITE_SIZED_CREATURE_CATALOG,
|
||||
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 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", () => {
|
||||
it("keeps source geometry and both existing Stockades definitions immutable", () => {
|
||||
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.navigationLinks).toEqual(source.navigationLinks);
|
||||
@@ -26,34 +38,85 @@ describe("Randomized Bite-Sized Stockades", () => {
|
||||
expect(source.staticSpawns).toEqual(originalSpawns);
|
||||
});
|
||||
|
||||
it("rerolls every ordinary slot from the complete placed Stockades mob pool", () => {
|
||||
const catalog = randomizedBiteSizedCreatureCatalog(source);
|
||||
const mobIds = catalog.filter((candidate) => candidate.kind === "mob").map((candidate) => candidate.entityId);
|
||||
const observed = new Set<string>();
|
||||
for (let index = 0; index < 2_000; index += 1) {
|
||||
const definition = createRandomizedBiteSizedStockadesDefinition(source, `mob-roll-${index}`);
|
||||
const bossId = definition.bosses[0].id;
|
||||
for (const spawn of definition.staticSpawns.filter((candidate) => candidate.id !== bossId)) {
|
||||
expect(mobIds).toContain(spawn.entityId);
|
||||
observed.add(spawn.entityId);
|
||||
}
|
||||
}
|
||||
expect([...observed].sort()).toEqual([...mobIds].sort());
|
||||
it("uses the complete model-backed cross-dungeon catalog regardless of installed packs", () => {
|
||||
const mobs = catalog.filter((candidate) => candidate.kind === "mob");
|
||||
const bosses = catalog.filter((candidate) => candidate.kind === "boss");
|
||||
|
||||
expect(mobs.length).toBeGreaterThan(500);
|
||||
expect(bosses.length).toBeGreaterThan(100);
|
||||
expect(new Set(catalog.map((candidate) => candidate.sourceDungeonId)).size).toBeGreaterThan(50);
|
||||
expect(catalog.some((candidate) => candidate.definition.visual.model?.url.startsWith("/assets/creatures/wailing-caverns/"))).toBe(true);
|
||||
expect(catalog.some((candidate) => candidate.definition.visual.model?.url.startsWith("/assets/game/creatures/shared/"))).toBe(true);
|
||||
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.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", () => {
|
||||
const first = createRandomizedBiteSizedStockadesDefinition(source, "shared-run");
|
||||
const second = createRandomizedBiteSizedStockadesDefinition(source, "shared-run");
|
||||
it("replaces every corridor spawn and deals distinct mobs without replacement", () => {
|
||||
const randomized = create("every-slot-is-random");
|
||||
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.bosses).toEqual(first.bosses);
|
||||
|
||||
const bosses = new Set<string>();
|
||||
for (let index = 0; index < 1_000; index += 1) {
|
||||
bosses.add(createRandomizedBiteSizedStockadesDefinition(source, `boss-roll-${index}`).bosses[0].id);
|
||||
const bossEntries = new Set(catalog.filter((candidate) => candidate.kind === "boss").map((candidate) => candidate.serverEntry));
|
||||
const observedBossEntries = new Set<number>();
|
||||
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(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
@@ -2207,11 +2207,18 @@ html[data-display-layout="thor-preview"], html[data-display-layout="thor-preview
|
||||
|
||||
/* Main menu */
|
||||
.main-menu-screen {
|
||||
--main-menu-gold: #d2b16d;
|
||||
--main-menu-ice: #9fd9df;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(245px, 32%) minmax(0, 1fr);
|
||||
grid-template-rows: 54px minmax(0, 1fr) 25px;
|
||||
column-gap: 22px;
|
||||
padding-bottom: max(8px, env(safe-area-inset-bottom));
|
||||
grid-template-columns: minmax(0, 1fr) minmax(285px, 31%);
|
||||
grid-template-rows: 58px minmax(0, 1fr) 25px;
|
||||
gap: 12px;
|
||||
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 {
|
||||
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) 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 {
|
||||
margin-left: auto;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--front-line);
|
||||
border-radius: 7px;
|
||||
background: rgba(255,255,255,.025);
|
||||
color: var(--front-dim);
|
||||
border: 1px solid rgba(210,177,109,.3);
|
||||
border-radius: 4px;
|
||||
background: rgba(8,17,21,.74);
|
||||
color: #d8d2c3;
|
||||
cursor: pointer;
|
||||
font-size: 9px;
|
||||
}
|
||||
.main-menu-character {
|
||||
grid-column: 1;
|
||||
.main-menu-header { padding: 0 4px 5px; border-bottom-color: rgba(210,177,109,.34); }
|
||||
.main-menu-header .front-brand > span:last-child { justify-content: center; }
|
||||
.main-menu-sidebar {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
grid-template-rows: minmax(0, 1fr) auto;
|
||||
border-right: 1px solid var(--front-line);
|
||||
padding-right: 2px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.main-menu-character__preview { min-width: 0; min-height: 0; }
|
||||
.main-menu-character__preview .character-preview__viewport { bottom: 0; }
|
||||
.main-menu-character__preview .character-preview figcaption { display: none; }
|
||||
.main-menu-character__summary { padding: 11px 18px 18px 2px; border-top: 1px solid rgba(204,224,180,.1); }
|
||||
.main-menu-character__summary h1 { overflow: hidden; margin: 4px 0 2px; font: 27px Georgia, serif; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.main-menu-character {
|
||||
display: grid;
|
||||
grid-template-columns: 54px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
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 { font-size: 10px; }
|
||||
.main-menu-character__summary > small { margin-top: 4px; color: var(--front-dim); font-size: 8px; text-transform: capitalize; }
|
||||
.main-menu-modes { grid-column: 2; grid-row: 2; min-width: 0; min-height: 0; padding: 18px 0 12px; overflow: auto; }
|
||||
.main-menu-modes > header h2 { margin: 3px 0 4px; font: 28px Georgia, serif; font-weight: 400; }
|
||||
.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-mode-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 14px; }
|
||||
.main-menu-character__summary > strong { overflow: hidden; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.main-menu-character__summary > small { color: #9fb2b0; font-size: 8px; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.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-gm-stack { display: grid; gap: 8px; }
|
||||
.main-menu-mode {
|
||||
position: relative;
|
||||
@@ -2821,7 +2840,7 @@ html[data-display-surface="bottom"] .single-context-toggle { display: none; }
|
||||
.roster-header { grid-column: 1 / 3; }
|
||||
.character-select-screen > .controller-legend { grid-column: 1 / 3; }
|
||||
.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; }
|
||||
.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; }
|
||||
@@ -2856,18 +2875,15 @@ html[data-display-surface="bottom"] .single-context-toggle { display: none; }
|
||||
.roster-footer__status { display: none; }
|
||||
.roster-enter { width: min(280px, 62%); margin-left: auto; }
|
||||
.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 > div:nth-child(2) strong { font-size: 14px; }
|
||||
.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-character__preview { grid-column: 1; }
|
||||
.main-menu-character__preview .character-preview__aura { width: 75px; }
|
||||
.main-menu-character__summary { grid-column: 2; align-self: center; padding: 7px 8px; border-top: 0; }
|
||||
.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-sidebar > .online-group-panel { margin: 0; }
|
||||
.main-menu-character { align-self: start; }
|
||||
.main-menu-character__summary h1 { font-size: 20px; }
|
||||
.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: 9px; }
|
||||
.main-menu-mode-grid { margin-top: 0; }
|
||||
.main-menu-mode { min-height: 64px; }
|
||||
.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; }
|
||||
@@ -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; }
|
||||
.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-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; }
|
||||
|
||||
@@ -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 (
|
||||
<div className={`front-brand ${compact ? "front-brand--compact" : ""}`}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
+17
-23
@@ -1,9 +1,9 @@
|
||||
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 { DualDisplayFrame } from "../components/DualDisplayFrame";
|
||||
import { specializationById, specializationIconUrl } from "../game/specializationCatalog";
|
||||
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
||||
import { CharacterPreview } from "./CharacterPreview";
|
||||
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
||||
import { ControllerButton } from "./ControllerButton";
|
||||
import { ContentManagerScreen } from "./ContentManagerScreen";
|
||||
@@ -170,8 +170,9 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
onBack: returnToCharacters,
|
||||
});
|
||||
|
||||
const race = character ? raceById(character.raceId) : null;
|
||||
const characterClass = character ? classById(character.classId) : null;
|
||||
const specialization = specializationById(character?.specializationId);
|
||||
const secondaryClass = character?.secondaryClassId ? classById(character.secondaryClassId) : null;
|
||||
|
||||
return (
|
||||
<DualDisplayFrame
|
||||
@@ -179,11 +180,7 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
top={
|
||||
<FrontSurface className="main-menu-screen" ariaLabel="Healer Man main menu">
|
||||
<header className="main-menu-header">
|
||||
<BrandMark compact />
|
||||
<div>
|
||||
<span>Adventurer selected</span>
|
||||
<strong>Choose your next journey</strong>
|
||||
</div>
|
||||
<BrandMark compact tagline={null} />
|
||||
<ControllerButton
|
||||
controlId="back-to-roster"
|
||||
selectedId={controller.selectedId}
|
||||
@@ -195,30 +192,27 @@ function MainMenuDestinations({ onOpenDownloads, onOpenReports, onOpenCards, onO
|
||||
</ControllerButton>
|
||||
</header>
|
||||
|
||||
<aside className="main-menu-character" aria-label="Selected character">
|
||||
<div className="main-menu-character__preview">
|
||||
<CharacterPreview character={character} />
|
||||
</div>
|
||||
{character && race && characterClass && (
|
||||
<aside className="main-menu-sidebar" aria-label="Character and online group">
|
||||
{character && characterClass && (
|
||||
<section className="main-menu-character" aria-label="Selected character">
|
||||
<span className="main-menu-character__icon" style={{ borderColor: characterClass.color }}>
|
||||
<img src={specializationIconUrl(character.classId, character.specializationId)} alt="" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="main-menu-character__summary">
|
||||
<p className="front-kicker">Ready for adventure</p>
|
||||
<small>Level {character.level}</small>
|
||||
<h1>{character.name}</h1>
|
||||
<strong style={{ color: characterClass.color }}>
|
||||
Level {character.level} {race.name} {characterClass.name}
|
||||
{characterClass.name}{isRom
|
||||
? secondaryClass ? ` / ${secondaryClass.name}` : " / No secondary class"
|
||||
: specialization ? ` / ${specialization.displayName}` : ""}
|
||||
</strong>
|
||||
<small>{FACTIONS[race.faction].name} / {character.gender}</small>
|
||||
{isRom && <small>RoM / {character.secondaryClassId ? `${characterClass.name} + ${classById(character.secondaryClassId).name}` : "Secondary class not chosen"}</small>}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<OnlineGroupPanel defaultOpen />
|
||||
</aside>
|
||||
|
||||
<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">
|
||||
<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}>
|
||||
|
||||
@@ -9,9 +9,12 @@ import {
|
||||
type PartyRole,
|
||||
} 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 [panelOpen, setPanelOpen] = useState(false);
|
||||
const [panelOpen, setPanelOpen] = useState(defaultOpen);
|
||||
const session = useShellStore((state) => state.session);
|
||||
const character = useShellStore(selectedCharacter);
|
||||
const snapshot = useOnlineGroupStore((state) => state.snapshot);
|
||||
|
||||
@@ -8,22 +8,21 @@ import { dungeonContentIsUsable, installContentPack } from "../content/contentMa
|
||||
import { useContentManagerSnapshot } from "../content/useContentManager";
|
||||
import {
|
||||
RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID,
|
||||
STORMWIND_STOCKADE_DUNGEON_ID,
|
||||
dungeonContentPackId,
|
||||
} from "../game/dungeonContentSource";
|
||||
import { randomizedBiteSizedCreatureCatalog } from "../game/dungeons/randomizedBiteSizedStockades";
|
||||
import { dungeonById } from "../game/dungeonCatalog";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { RANDOMIZED_BITE_SIZED_CREATURE_CATALOG, requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
import { DungeonDifficultySlider } from "./DungeonDifficultySlider";
|
||||
import { OnlineGroupPanel } from "./OnlineGroupPanel";
|
||||
import { CompanionPoolControls } from "./CompanionPoolControls";
|
||||
import { CompanionReadinessScreen } from "./CompanionReadinessScreen";
|
||||
import { ensureRandomizedBiteSizedRunContent } from "../app/randomizedBiteSizedContent";
|
||||
|
||||
installDungeonShellRuntime();
|
||||
|
||||
const DUNGEON = dungeonById(RANDOMIZED_BITE_SIZED_STOCKADES_DUNGEON_ID);
|
||||
const CANDIDATES = randomizedBiteSizedCreatureCatalog(requireDungeonDefinition(STORMWIND_STOCKADE_DUNGEON_ID));
|
||||
const CANDIDATES = RANDOMIZED_BITE_SIZED_CREATURE_CATALOG;
|
||||
|
||||
export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () => void }) {
|
||||
useContentManagerSnapshot();
|
||||
@@ -51,11 +50,21 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
|
||||
const launch = async (role: PartyRole) => {
|
||||
if (!character || !DUNGEON?.available) return;
|
||||
if (!onlineGroup || !session || session.kind !== "account") {
|
||||
const runId = globalThis.crypto.randomUUID();
|
||||
setDownloading(true); setError("");
|
||||
try {
|
||||
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;
|
||||
}
|
||||
if (!await updateOnlineMember(session, character, role)) return;
|
||||
@@ -99,9 +108,9 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
|
||||
</header>
|
||||
<main className="randomized-bite-sized-screen__layout">
|
||||
<section className="randomized-bite-sized-screen__hero">
|
||||
<p className="front-kicker">A different cellblock every run</p>
|
||||
<p className="front-kicker">A different lineup every run</p>
|
||||
<h1>Randomized Bite-Sized</h1>
|
||||
<p>Fight through the short Stockades route with every ordinary enemy slot rerolled, then face one randomly selected warden.</p>
|
||||
<p>The mode picks an eligible short layout—currently Cellblock Rush—deals a different cross-dungeon creature into every enemy slot, then rolls the boss separately.</p>
|
||||
<div className="randomized-bite-sized-screen__odds">
|
||||
<div><strong>{mobCount}</strong><span>mob types in the shuffle</span></div>
|
||||
<div><strong>{bossCount}</strong><span>possible final bosses</span></div>
|
||||
@@ -116,11 +125,11 @@ export function RandomizedBiteSizedScreen({ onClose }: { readonly onClose: () =>
|
||||
<h2>{DUNGEON?.title ?? "Cellblock Shuffle"}</h2>
|
||||
<DungeonDifficultySlider value={bossDifficulty} onChange={setBossDifficulty} selectedId="" select={() => undefined} />
|
||||
{!installed ? (
|
||||
<button className="front-button front-button--primary" type="button" disabled={downloading} onClick={() => void install()}>{downloading ? "Installing…" : "Install Stockades content"}</button>
|
||||
<button className="front-button front-button--primary" type="button" disabled={downloading} onClick={() => void install()}>{downloading ? "Installing…" : "Install randomized dungeon content"}</button>
|
||||
) : roles.length ? (
|
||||
<div className="randomized-bite-sized-screen__roles">
|
||||
<strong>Enter as</strong>
|
||||
{roles.map((role) => <button className="front-button front-button--primary" key={role} type="button" disabled={!DUNGEON?.available} 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>
|
||||
) : <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>}
|
||||
|
||||
@@ -6,17 +6,11 @@ import {
|
||||
useRandomizedCreatureReadiness,
|
||||
} from "../app/randomizedCreatureReadiness";
|
||||
import { useShellStore } from "../app/shellStore";
|
||||
import { STORMWIND_STOCKADE_DUNGEON_ID } from "../game/dungeonContentSource";
|
||||
import {
|
||||
randomizedBiteSizedCreatureCatalog,
|
||||
type RandomizedCreatureKind,
|
||||
} from "../game/dungeons/randomizedBiteSizedStockades";
|
||||
import { requireDungeonDefinition } from "../game/dungeonRegistry";
|
||||
import type { RandomizedCreatureKind } from "../game/dungeons/randomizedBiteSizedStockades";
|
||||
import { RANDOMIZED_BITE_SIZED_CREATURE_CATALOG } from "../game/dungeonRegistry";
|
||||
import { BrandMark, FrontSurface } from "./FrontSurface";
|
||||
|
||||
const CREATURES = randomizedBiteSizedCreatureCatalog(
|
||||
requireDungeonDefinition(STORMWIND_STOCKADE_DUNGEON_ID),
|
||||
);
|
||||
const CREATURES = RANDOMIZED_BITE_SIZED_CREATURE_CATALOG;
|
||||
|
||||
type KindFilter = "all" | RandomizedCreatureKind;
|
||||
type StatusFilter = "all" | CreatureReadinessStatus;
|
||||
|
||||
@@ -125,6 +125,13 @@
|
||||
.roster-character-info { width: min(215px, 42%); padding: 13px; }
|
||||
.roster-character-info h1 { font-size: 30px; }
|
||||
.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; }
|
||||
.creator-header, .creator-progress, .creator-footer { grid-column: 1 / 4; }
|
||||
.creator-races { grid-column: 1; }
|
||||
|
||||
Reference in new issue
Block a user