import type { AppearanceCounts, GenderId, RaceId } from "../app/characterCatalog"; import { contentPackIsUsable, resolveContentUrl, } from "../content/contentManager"; export type TextureTriplet = readonly [string, string, string]; export type TextureRegion = readonly [number, number, number, number]; export interface AvatarSkinOption { dbcColor: number; base: string; underwear: TextureTriplet; } export interface AvatarFaceOption { dbcVariation: number; bySkin: readonly TextureTriplet[]; } export interface AvatarHairOption { dbcVariation: number; geoset: number; showScalp: boolean; byColor: readonly TextureTriplet[]; } export interface AvatarFeatureOption { dbcVariation: number; geosets: readonly number[]; byColor: readonly TextureTriplet[]; } export interface AvatarAttachmentPoint { bone: number; position: readonly [number, number, number]; } export interface RunewakerModularRendererStrategy { kind: "runewaker-modular"; nativeBodyRig: true; sharedSkeleton: true; starterPaperdoll: true; cachedDimensions: readonly string[]; sourceParts: readonly string[]; skinPalette: readonly string[]; hairPalette: readonly string[]; } export interface AvatarManifestEntry { raceId: RaceId; gender: GenderId; dbcRaceId: number; dbcSexId: number; sourceModel: string; model: string; bytes: number; scale: number; groundOffset: number; rotationY: number; materialTokens: { body: string; hair: string }; animations: { idle: string; walk: string; run: string; attack?: string; cast?: string; wound?: string; death?: string; }; counts: AppearanceCounts; dbcIndices: Record; skinOptions: readonly AvatarSkinOption[]; faceOptions: readonly AvatarFaceOption[]; hairOptions: readonly AvatarHairOption[]; featureOptions: readonly AvatarFeatureOption[]; attachmentPoints?: Readonly>; rendererStrategy?: RunewakerModularRendererStrategy; /** Runtime ownership; generated manifests may omit it. */ contentPackId?: string; } export interface AvatarManifest { version: number; sourceBuild: string; publicRoot: string; textureLayout: { width: number; height: number; regions: { base: TextureRegion; faceUpper: TextureRegion; faceLower: TextureRegion; torsoUpper: TextureRegion; pelvisUpper: TextureRegion; }; }; entries: Record; } /** Backward-compatible name used by asset verification and older build tools. */ export type CharacterManifest = AvatarManifest; export const AVATAR_MANIFEST_URL = "/assets/characters/wow335a-v1/manifest.json"; export const BASIC_CHARACTER_CONTENT_PACK_ID = "core:character-basic"; export const WOW_CHARACTER_CONTENT_PACK_ID = "characters:wow335a-v1"; export const AVATAR_MANIFEST_SOURCES = [ { packIds: [BASIC_CHARACTER_CONTENT_PACK_ID, WOW_CHARACTER_CONTENT_PACK_ID], url: AVATAR_MANIFEST_URL, }, { packIds: ["characters:ascension-v1"], url: "/assets/characters/ascension-v1/manifest.json", }, { packIds: ["characters:runewaker-v1"], url: "/assets/characters/runewaker-v1/manifest.json", }, ] as const; export const AVATAR_MANIFEST_URLS = AVATAR_MANIFEST_SOURCES.map((source) => source.url); let manifestRequest: { readonly key: string; readonly promise: Promise } | null = null; interface AvatarManifestResponse { readonly ok: boolean; readonly status: number; json: () => Promise; } export function avatarManifestKey(raceId: RaceId, gender: GenderId): string { return `${raceId}:${gender}`; } export function availableAvatarManifestUrls( isUsable: (packId: string) => boolean = contentPackIsUsable, ): readonly string[] { return AVATAR_MANIFEST_SOURCES .filter((source) => source.packIds.some((packId) => isUsable(packId))) .map((source) => source.url); } function contentPackForAvatarEntry(url: string, entry: AvatarManifestEntry): string { if (url === AVATAR_MANIFEST_URL) { return entry.raceId === "human" ? BASIC_CHARACTER_CONTENT_PACK_ID : WOW_CHARACTER_CONTENT_PACK_ID; } return AVATAR_MANIFEST_SOURCES.find((source) => source.url === url)?.packIds[0] ?? WOW_CHARACTER_CONTENT_PACK_ID; } function mergeAvatarManifests( sources: readonly { readonly url: string; readonly manifest: AvatarManifest }[], ): AvatarManifest { const [{ manifest: baseManifest }, ...extensions] = sources; return { ...baseManifest, sourceBuild: sources.map(({ manifest }) => manifest.sourceBuild).join(" + "), entries: Object.assign({}, ...sources.map(({ manifest, url }) => Object.fromEntries( Object.entries(manifest.entries).map(([key, entry]) => [ key, { ...entry, contentPackId: contentPackForAvatarEntry(url, entry) }, ]), ))), }; } /** * Loads each shipped character pack independently. An unavailable optional * pack must never hide the valid rigs from another pack. */ export async function loadAvatarManifestSources( urls: readonly string[], request: (url: string) => Promise, ): Promise { const results = await Promise.allSettled(urls.map(async (url) => { const response = await request(url); if (!response.ok) throw new Error(`Avatar manifest request failed for ${url} (${response.status})`); const manifest = await response.json() as AvatarManifest; if (manifest.version !== 1 || !manifest.entries) { throw new Error(`Avatar manifest is malformed: ${url}`); } return { url, manifest }; })); const sources = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []); if (!sources.length) { const firstFailure = results.find((result) => result.status === "rejected"); throw firstFailure?.status === "rejected" ? firstFailure.reason : new Error("No playable-character manifests are available"); } results.forEach((result, index) => { if (result.status === "rejected") { console.warn(`Optional playable-character pack could not be loaded: ${urls[index]}`, result.reason); } }); return mergeAvatarManifests(sources); } export async function loadAvatarManifest(): Promise { const urls = availableAvatarManifestUrls(); if (!urls.length) throw new Error("No optional playable-character packs are installed"); const key = urls.join("|"); if (manifestRequest?.key !== key) { const promise = loadAvatarManifestSources( urls, (url) => fetch(resolveContentUrl(url), { cache: "force-cache" }), ) .catch((error) => { if (manifestRequest?.promise === promise) manifestRequest = null; throw error; }); manifestRequest = { key, promise }; } return manifestRequest.promise; } function firstOrEmpty(values: readonly T[]): readonly T[] { return values.length ? [values[0]] : []; } function basicHumanEntry(entry: AvatarManifestEntry): AvatarManifestEntry { const skin = entry.skinOptions?.[0]; const face = entry.faceOptions?.[0]; const hair = entry.hairOptions?.[0]; const feature = entry.featureOptions?.[0]; return { ...entry, contentPackId: BASIC_CHARACTER_CONTENT_PACK_ID, counts: { skinColor: 1, face: 1, hairStyle: 1, hairColor: 1, feature: 1 }, dbcIndices: { skinColor: firstOrEmpty(entry.dbcIndices?.skinColor ?? []), face: firstOrEmpty(entry.dbcIndices?.face ?? []), hairStyle: firstOrEmpty(entry.dbcIndices?.hairStyle ?? []), hairColor: firstOrEmpty(entry.dbcIndices?.hairColor ?? []), feature: firstOrEmpty(entry.dbcIndices?.feature ?? []), }, skinOptions: skin ? [skin] : [], faceOptions: face ? [{ ...face, bySkin: firstOrEmpty(face.bySkin) }] : [], hairOptions: hair ? [{ ...hair, byColor: firstOrEmpty(hair.byColor) }] : [], featureOptions: feature ? [{ ...feature, byColor: firstOrEmpty(feature.byColor) }] : [], }; } export function getAvatarEntry( manifest: AvatarManifest, raceId: RaceId, gender: GenderId, isUsable: (packId: string) => boolean = contentPackIsUsable, ): AvatarManifestEntry | null { const requested = manifest.entries[avatarManifestKey(raceId, gender)] ?? null; if (requested) { const requestedPackUsable = !requested.contentPackId || isUsable(requested.contentPackId); if (requested.raceId === "human") { if (requestedPackUsable && isUsable(WOW_CHARACTER_CONTENT_PACK_ID)) return requested; } else if (requestedPackUsable) { return requested; } } const basicHuman = manifest.entries[avatarManifestKey("human", gender)] ?? manifest.entries[avatarManifestKey("human", "male")] ?? null; return basicHuman && (!basicHuman.contentPackId || isUsable(basicHuman.contentPackId)) ? basicHumanEntry(basicHuman) : null; }