import { createHash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; const pipelineDirectory = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(pipelineDirectory, "../.."); const packRoot = path.join(projectRoot, "public/assets/characters/runewaker-v1"); const importFile = path.join(packRoot, "import-manifest.json"); const outputFile = path.join(packRoot, "manifest.json"); const imported = JSON.parse(await readFile(importFile, "utf8")); if (imported.status !== "green" || imported.assets?.length !== 5) { throw new Error("RuneWaker player avatar import must contain all five validated native rigs."); } const identityByAsset = { "rom-human-male": ["rom-human", "male", 1, 0], "rom-human-female": ["rom-human", "female", 1, 1], "rom-elf-male": ["rom-elf", "male", 2, 0], "rom-elf-female": ["rom-elf", "female", 2, 1], "rom-dwarf-male": ["rom-dwarf", "male", 3, 0], }; const counts = { skinColor: 8, face: 8, hairStyle: 8, hairColor: 8, feature: 8 }; const indices = Array.from({ length: 8 }, (_, index) => index); const triplet = ["", "", ""]; const blankByColor = indices.map(() => triplet); const skinOptions = indices.map((index) => ({ dbcColor: index, base: "", underwear: triplet, })); const faceOptions = indices.map((index) => ({ dbcVariation: index, bySkin: blankByColor })); const hairOptions = indices.map((index) => ({ dbcVariation: index, geoset: 1, showScalp: true, byColor: blankByColor, })); const featureOptions = indices.map((index) => ({ dbcVariation: index, geosets: [], byColor: blankByColor, })); const entries = Object.fromEntries(imported.assets.map((asset) => { const identity = identityByAsset[asset.id]; if (!identity) throw new Error(`Unexpected RuneWaker player avatar ${asset.id}.`); const [raceId, gender, dbcRaceId, dbcSexId] = identity; const requiredClips = [ "Stand - stand_idle01", "Walk - walk_forward", "Run - run_forward", "Attack - unarmed_attack01", "Cast - cast01", "Wound - hurt", "Death - death", ]; for (const clip of requiredClips) { if (!asset.animations.includes(clip)) { throw new Error(`${asset.id} is missing required native animation ${clip}.`); } } return [`${raceId}:${gender}`, { raceId, gender, dbcRaceId, dbcSexId, sourceModel: asset.sourceModel, model: asset.url, bytes: asset.size, scale: 1, groundOffset: asset.groundOffset, rotationY: asset.rotationY, materialTokens: { body: "", hair: "" }, animations: { idle: "Stand - stand_idle01", walk: "Walk - walk_forward", run: "Run - run_forward", attack: "Attack - unarmed_attack01", cast: "Cast - cast01", wound: "Wound - hurt", death: "Death - death", }, counts, dbcIndices: { skinColor: indices, face: indices, hairStyle: indices, hairColor: indices, feature: indices, }, skinOptions, faceOptions, hairOptions, featureOptions, rendererStrategy: { kind: "runewaker-modular", nativeBodyRig: true, sharedSkeleton: true, starterPaperdoll: true, cachedDimensions: ["skinColor", "face", "hairStyle", "hairColor", "feature", "gear"], sourceParts: ["head", "hair", "torso", "hand", "leg", "foot"], skinPalette: ["#f2d1bc", "#deb39a", "#c89373", "#aa7354", "#82533f", "#eed4c7", "#d6a887", "#9d654c"], hairPalette: ["#211a17", "#493124", "#73482a", "#9c713f", "#b9a27a", "#7a2630", "#c8c3b4", "#342c43"], }, provenance: { glbSha256: asset.checksum, sourceModelSha256: asset.sourceModelSha256, assemblySha256: asset.paperdollAssemblySha256, nativeClipCount: asset.animations.length, }, }]; })); const sourceMaterial = Object.values(entries) .map((entry) => `${entry.provenance.sourceModelSha256}:${entry.provenance.glbSha256}`) .join("|"); const manifest = { version: 1, sourceBuild: `runewaker-v1-${createHash("sha256").update(sourceMaterial).digest("hex").slice(0, 16)}`, publicRoot: "/assets/characters/runewaker-v1", textureLayout: { width: 1, height: 1, regions: { base: [0, 0, 1, 1], faceUpper: [0, 0, 1, 1], faceLower: [0, 0, 1, 1], torsoUpper: [0, 0, 1, 1], pelvisUpper: [0, 0, 1, 1], }, }, entries, provenance: { generator: "scripts/runewaker-pipeline/finalize-player-avatar-manifest.mjs", importManifest: "public/assets/characters/runewaker-v1/import-manifest.json", runtimeRuneWakerDependency: false, renderer: "native shared-skeleton modular paperdoll", }, }; await writeFile(outputFile, JSON.stringify(manifest, null, 2) + "\n", "utf8"); console.log(`Generated ${Object.keys(entries).length} RuneWaker playable avatar rigs.`);