From 97ee0c13f958c211c30eabac0a644cccb78855db Mon Sep 17 00:00:00 2001 From: phenom Date: Sun, 20 Sep 2026 18:08:43 -0400 Subject: [PATCH] Update Healer Man server --- PLAYTEST_NOTES.md | 13 ++ scripts/content/publish-content.mjs | 7 + scripts/content/publish-content.test.mjs | 10 + server/dungeon-reports.mjs | 2 +- server/dungeon-reports.test.mjs | 4 +- shared/content-policy.json | 3 +- src/app/OnlineGroupCoordinator.test.ts | 15 ++ src/app/OnlineGroupCoordinator.tsx | 9 + src/app/randomizedBiteSizedContent.test.ts | 46 +++++ src/app/randomizedBiteSizedContent.ts | 47 +++++ src/content/contentManager.ts | 21 ++ src/content/contentManifest.test.ts | 25 +++ src/content/contentManifest.ts | 20 ++ src/game/dungeonContentSource.test.ts | 2 +- src/game/dungeonContentSource.ts | 4 + src/game/dungeonRegistry.ts | 20 +- .../dungeons/randomizedBiteSizedStockades.ts | 192 ++++++++++++------ src/game/randomizedBiteSizedStockades.test.ts | 117 ++++++++--- src/styles.css | 83 +++++--- src/ui/FrontSurface.tsx | 4 +- src/ui/MainMenuScreen.tsx | 50 ++--- src/ui/OnlineGroupPanel.tsx | 7 +- src/ui/RandomizedBiteSizedScreen.tsx | 35 ++-- src/ui/RandomizedCreatureReadinessScreen.tsx | 12 +- src/ui/thorUi.css | 7 + 25 files changed, 576 insertions(+), 179 deletions(-) create mode 100644 src/app/randomizedBiteSizedContent.test.ts create mode 100644 src/app/randomizedBiteSizedContent.ts diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index 4dc17bd2..0705490e 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -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. diff --git a/scripts/content/publish-content.mjs b/scripts/content/publish-content.mjs index effe13e2..81cc939a 100644 --- a/scripts/content/publish-content.mjs +++ b/scripts/content/publish-content.mjs @@ -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); diff --git a/scripts/content/publish-content.test.mjs b/scripts/content/publish-content.test.mjs index f1bcc81f..551a6b78 100644 --- a/scripts/content/publish-content.test.mjs +++ b/scripts/content/publish-content.test.mjs @@ -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"); diff --git a/server/dungeon-reports.mjs b/server/dungeon-reports.mjs index 0f796a49..fd005f37 100644 --- a/server/dungeon-reports.mjs +++ b/server/dungeon-reports.mjs @@ -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)) { diff --git a/server/dungeon-reports.test.mjs b/server/dungeon-reports.test.mjs index e7c98338..df212139 100644 --- a/server/dungeon-reports.test.mjs +++ b/server/dungeon-reports.test.mjs @@ -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) }); diff --git a/shared/content-policy.json b/shared/content-policy.json index 6f4b56fd..ac9e886d 100644 --- a/shared/content-policy.json +++ b/shared/content-policy.json @@ -11,7 +11,8 @@ "core:arenas" ], "hiddenContentPackPrefixes": [ - "manastorm:" + "manastorm:", + "activity:" ], "maxBootstrapStoredBytes": 83886080 } diff --git a/src/app/OnlineGroupCoordinator.test.ts b/src/app/OnlineGroupCoordinator.test.ts index 53eb6595..988b3aa7 100644 --- a/src/app/OnlineGroupCoordinator.test.ts +++ b/src/app/OnlineGroupCoordinator.test.ts @@ -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); diff --git a/src/app/OnlineGroupCoordinator.tsx b/src/app/OnlineGroupCoordinator.tsx index d4908918..fe0d1ba6 100644 --- a/src/app/OnlineGroupCoordinator.tsx +++ b/src/app/OnlineGroupCoordinator.tsx @@ -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; readonly installDungeonRuntime: () => Promise; readonly enterDungeon: (dungeonId: string, role?: PartyRole, settings?: DungeonEntrySettings) => boolean; readonly installManastormRuntime: () => Promise; @@ -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 { 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, diff --git a/src/app/randomizedBiteSizedContent.test.ts b/src/app/randomizedBiteSizedContent.test.ts new file mode 100644 index 00000000..82de9836 --- /dev/null +++ b/src/app/randomizedBiteSizedContent.test.ts @@ -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(); + }); +}); diff --git a/src/app/randomizedBiteSizedContent.ts b/src/app/randomizedBiteSizedContent.ts new file mode 100644 index 00000000..5ac40ad7 --- /dev/null +++ b/src/app/randomizedBiteSizedContent.ts @@ -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; + readonly installUrls: (urls: readonly string[]) => Promise; + 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 { + 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)); +} diff --git a/src/content/contentManager.ts b/src/content/contentManager.ts index 444053f4..45da7642 100644 --- a/src/content/contentManager.ts +++ b/src/content/contentManager.ts @@ -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 { } } +/** Installs only the packs owning the selected run's logical model URLs. */ +export async function installContentForLogicalUrls(logicalUrls: readonly string[]): Promise { + 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 { if (contentPackIsBundled(packId)) return; const remainingFiles = { ...installedState.files }; diff --git a/src/content/contentManifest.test.ts b/src/content/contentManifest.test.ts index 5c824dad..ffbede50 100644 --- a/src/content/contentManifest.test.ts +++ b/src/content/contentManifest.test.ts @@ -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); + }); }); diff --git a/src/content/contentManifest.ts b/src/content/contentManifest.ts index 31a0e859..146fe3c7 100644 --- a/src/content/contentManifest.ts +++ b/src/content/contentManifest.ts @@ -32,6 +32,26 @@ export interface ContentManifest { readonly files: Readonly>; } +/** 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(); + 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 { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } diff --git a/src/game/dungeonContentSource.test.ts b/src/game/dungeonContentSource.test.ts index 404197cc..eeeb6413 100644 --- a/src/game/dungeonContentSource.test.ts +++ b/src/game/dungeonContentSource.test.ts @@ -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"); }); }); diff --git a/src/game/dungeonContentSource.ts b/src/game/dungeonContentSource.ts index bb2cb67f..4341acfa 100644 --- a/src/game/dungeonContentSource.ts +++ b/src/game/dungeonContentSource.ts @@ -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> = 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)}`; } diff --git a/src/game/dungeonRegistry.ts b/src/game/dungeonRegistry.ts index b2a411b2..025d164a 100644 --- a/src/game/dungeonRegistry.ts +++ b/src/game/dungeonRegistry.ts @@ -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; } diff --git a/src/game/dungeons/randomizedBiteSizedStockades.ts b/src/game/dungeons/randomizedBiteSizedStockades.ts index 5656fdfb..016157b5 100644 --- a/src/game/dungeons/randomizedBiteSizedStockades.ts +++ b/src/game/dungeons/randomizedBiteSizedStockades.ts @@ -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 { - 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(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 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)); + return shuffled; } /** - * 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. + * 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, + 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( 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>; + const selectedCandidates = [...selectedMobs.values(), bossCandidate]; + const entities = Object.fromEntries(selectedCandidates.map((candidate) => { + const entity = runtimeEntity(candidate); + return [entity.id, entity]; + })) as Readonly>; + 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, })), diff --git a/src/game/randomizedBiteSizedStockades.test.ts b/src/game/randomizedBiteSizedStockades.test.ts index fdf79f0c..5570febe 100644 --- a/src/game/randomizedBiteSizedStockades.test.ts +++ b/src/game/randomizedBiteSizedStockades.test.ts @@ -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): 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(); - 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(); - 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(); + const observedTrashLineups = new Set(); + for (let index = 0; index < 2_000; index += 1) { + const definition = create(`roll-${index}`); + const entry = bossEntityServerEntry(definition); + if (entry) observedBossEntries.add(entry); + const 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 }); + }); }); diff --git a/src/styles.css b/src/styles.css index 19859603..2b5035f8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/src/ui/FrontSurface.tsx b/src/ui/FrontSurface.tsx index abc470c1..a3c9b528 100644 --- a/src/ui/FrontSurface.tsx +++ b/src/ui/FrontSurface.tsx @@ -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 (
- Answer the callHealer Man + {tagline && {tagline}}Healer Man
); } diff --git a/src/ui/MainMenuScreen.tsx b/src/ui/MainMenuScreen.tsx index 5d30eb75..ad951f1e 100644 --- a/src/ui/MainMenuScreen.tsx +++ b/src/ui/MainMenuScreen.tsx @@ -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 (
- -
- Adventurer selected - Choose your next journey -
+
-