diff --git a/PLAYTEST_NOTES.md b/PLAYTEST_NOTES.md index 625a9152..e7ed66e2 100644 --- a/PLAYTEST_NOTES.md +++ b/PLAYTEST_NOTES.md @@ -1839,3 +1839,18 @@ Vite's existing large-chunk advisory remains informational. no environment-performance regressions. The release-candidate check accepted all 118 changed paths. The existing large-chunk advisory and Rapier initialization deprecation warning remain informational. + +# 2026-09-09 — fixed per-rank classic mana costs + +- Imported WoW mana percentages are now converted into fixed costs at each + spell rank's source level. Player level, Intellect, equipment, and maximum-mana + modifiers no longer increase the cost of an already learned rank; legitimate + talent and aura cost reductions are still applied afterward. +- Shadow Word: Pain rank 4 now costs 133 mana at every level from 26 through 33, + including level 32. Learning rank 5 at level 34 raises the cost to 188 mana. +- AI companions now use real character-scale mana pools so fixed spell costs do + not prevent healing or resurrection. The companion display also resolves the + active spell rank and the same final affordability result as the primary HUD. +- Verification passed all 1,133 Vitest cases across 208 files. The production + build completed with 974 modules transformed; arena, KTX2, and environment- + performance audits remained green, and the release-candidate check passed. diff --git a/package-lock.json b/package-lock.json index 39e0ed33..eb212a3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "healer-man", - "version": "0.1.23", + "version": "0.1.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "healer-man", - "version": "0.1.23", + "version": "0.1.24", "dependencies": { "@capacitor/android": "8.4.1", "@capacitor/core": "8.4.1", diff --git a/package.json b/package.json index 2c251630..283ed34a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "healer-man", "private": true, - "version": "0.1.23", + "version": "0.1.24", "type": "module", "scripts": { "dev": "vite --host 0.0.0.0", @@ -95,6 +95,7 @@ "runewaker:audit:encounters": "node scripts/runewaker-pipeline/audit-encounter-implementation.mjs && node --test scripts/runewaker-pipeline/encounter-implementation-report.test.mjs", "runewaker:audit:environment-assembly": "node scripts/runewaker-pipeline/audit-environment-assembly.mjs && node --test scripts/runewaker-pipeline/environment-assembly.test.mjs", "runewaker:audit:navigation": "node scripts/runewaker-pipeline/analyze-navigation-gaps.mjs && vitest run src/game/runewakerObjectiveRouteAudit.test.ts", + "runewaker:audit:material-alpha": "node scripts/runewaker-pipeline/audit-material-alpha.mjs", "assets:wailing:instance": "gltf-transform instance ../wow335a/dungeon-export-work/WailingCaverns/converted/wailing-caverns.visual.glb src/assets/game/dungeons/wailing-caverns/wailing-caverns-visual.glb --min 3", "assets:wailing:creatures:animated": "node scripts/prepareAnimatedCreatures.mjs", "assets:wailing:creatures:combat": "node scripts/add-combat-creature-clips.mjs", diff --git a/release-version.json b/release-version.json index 434de420..089806d1 100644 --- a/release-version.json +++ b/release-version.json @@ -1,4 +1,4 @@ { - "versionName": "0.1.23", - "versionCode": 1023 + "versionName": "0.1.24", + "versionCode": 1024 } diff --git a/scripts/manastorm-assets/validate-package-glbs.cjs b/scripts/manastorm-assets/validate-package-glbs.cjs index c5e5a90c..15090cd0 100644 --- a/scripts/manastorm-assets/validate-package-glbs.cjs +++ b/scripts/manastorm-assets/validate-package-glbs.cjs @@ -36,6 +36,9 @@ async function main() { && hasBasisu && /^\/images\/\d+\/mimeType$/.test(issue.pointer ?? "") && issue.message.includes("'image/ktx2'")) return false; + if (issue.code === "IMAGE_UNRECOGNIZED_FORMAT" + && hasBasisu + && /^\/images\/\d+$/.test(issue.pointer ?? "")) return false; return true; }); rows.push({ diff --git a/scripts/runewaker-pipeline/audit-material-alpha.mjs b/scripts/runewaker-pipeline/audit-material-alpha.mjs new file mode 100644 index 00000000..2c8d442a --- /dev/null +++ b/scripts/runewaker-pipeline/audit-material-alpha.mjs @@ -0,0 +1,262 @@ +import { createHash } from "node:crypto"; +import { readFile, readdir, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; + +const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const recipeDirectory = path.join(projectRoot, "scripts", "runewaker-pipeline", "recipes"); +const workRoot = path.join(projectRoot, "runewaker-export-work"); +const shippingRoot = path.join(projectRoot, "src", "assets", "game", "dungeons"); +const resourceCacheRoot = path.join(workRoot, "resource-cache"); +const requestedSlugs = new Set(process.argv.slice(2).filter((value) => !value.startsWith("--"))); +const fix = process.argv.includes("--fix"); +const alphaClassifications = new Map(); +let resourceTextureIndex; + +async function exists(file) { + try { + await stat(file); + return true; + } catch { + return false; + } +} + +function parseGlb(buffer, file) { + if (buffer.length < 20 || buffer.toString("utf8", 0, 4) !== "glTF") { + throw new Error(`${file} is not a GLB file.`); + } + const chunks = []; + let json; + let offset = 12; + while (offset + 8 <= buffer.length) { + const chunkLength = buffer.readUInt32LE(offset); + const chunkType = buffer.readUInt32LE(offset + 4); + const chunkStart = offset + 8; + const chunkEnd = chunkStart + chunkLength; + if (chunkEnd > buffer.length) break; + const data = buffer.subarray(chunkStart, chunkEnd); + chunks.push({ type: chunkType, data }); + if (chunkType === 0x4e4f534a) { + json = JSON.parse(data.toString("utf8").replace(/\0+$/u, "").trim()); + } + offset = chunkEnd; + } + if (!json) throw new Error(`${file} does not contain a JSON chunk.`); + return { json, chunks }; +} + +function serializeGlb(source, json, chunks) { + const encodedJson = Buffer.from(JSON.stringify(json)); + const paddedJson = Buffer.alloc(Math.ceil(encodedJson.length / 4) * 4, 0x20); + encodedJson.copy(paddedJson); + const updatedChunks = chunks.map((chunk) => ( + chunk.type === 0x4e4f534a ? { ...chunk, data: paddedJson } : chunk + )); + const totalLength = 12 + updatedChunks.reduce((total, chunk) => total + 8 + chunk.data.length, 0); + const output = Buffer.alloc(totalLength); + source.copy(output, 0, 0, 12); + output.writeUInt32LE(totalLength, 8); + let offset = 12; + for (const chunk of updatedChunks) { + output.writeUInt32LE(chunk.data.length, offset); + output.writeUInt32LE(chunk.type, offset + 4); + chunk.data.copy(output, offset + 8); + offset += 8 + chunk.data.length; + } + return output; +} + +async function classifyAlpha(file) { + if (alphaClassifications.has(file)) return alphaClassifications.get(file); + const metadata = await sharp(file).metadata(); + if (!metadata.hasAlpha) { + alphaClassifications.set(file, "opaque"); + return "opaque"; + } + + const { data, info } = await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + let hasTransparentPixel = false; + for (let offset = 3; offset < data.length; offset += info.channels) { + const alpha = data[offset]; + if (alpha === 255) continue; + hasTransparentPixel = true; + if (alpha > 0) { + alphaClassifications.set(file, "blend"); + return "blend"; + } + } + const role = hasTransparentPixel ? "cutout" : "opaque"; + alphaClassifications.set(file, role); + return role; +} + +function sourceImageIndex(texture) { + return texture?.extensions?.KHR_texture_basisu?.source ?? texture?.source; +} + +async function indexResourceTextures() { + if (resourceTextureIndex) return resourceTextureIndex; + resourceTextureIndex = new Map(); + if (!(await exists(resourceCacheRoot))) return resourceTextureIndex; + const cacheDirectories = (await readdir(resourceCacheRoot, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .sort((left, right) => left.name.localeCompare(right.name)); + for (const cacheDirectory of cacheDirectories) { + const textureDirectory = path.join(resourceCacheRoot, cacheDirectory.name, "prepared", "textures"); + if (!(await exists(textureDirectory))) continue; + for (const texture of (await readdir(textureDirectory)).filter((file) => file.endsWith(".png")).sort()) { + if (!resourceTextureIndex.has(texture)) { + resourceTextureIndex.set(texture, path.join(textureDirectory, texture)); + } + } + } + return resourceTextureIndex; +} + +async function resolveSourceTexture(localDirectory, imageName) { + const fileName = `${imageName}.png`; + const localFile = path.join(localDirectory, fileName); + if (await exists(localFile)) return localFile; + return (await indexResourceTextures()).get(fileName); +} + +function sha256(buffer) { + return createHash("sha256").update(buffer).digest("hex"); +} + +async function refreshShippingMetadata(metadataFile, metadata, visualArtifact, visualBuffer) { + visualArtifact.size = visualBuffer.length; + visualArtifact.sha256 = sha256(visualBuffer); + await writeFile(metadataFile, `${JSON.stringify(metadata, null, 2)}\n`, "utf8"); + + const optimizationFile = path.join(path.dirname(metadataFile), "optimization-report.json"); + if (!(await exists(optimizationFile))) return; + const optimization = JSON.parse(await readFile(optimizationFile, "utf8")); + if (optimization.optimized) { + optimization.optimized.size = visualBuffer.length; + if (Object.hasOwn(optimization.optimized, "checksum")) { + optimization.optimized.checksum = sha256(visualBuffer); + } + } + await writeFile(optimizationFile, `${JSON.stringify(optimization, null, 2)}\n`, "utf8"); +} + +async function auditDungeon(recipe) { + const slug = recipe.slug; + const metadataFile = path.join(shippingRoot, slug, "import-metadata.json"); + const preparedTextureDirectory = path.join(workRoot, slug, "prepared", "textures"); + if (!(await exists(metadataFile))) { + return { slug, status: "incomplete", problems: [`Missing ${path.relative(projectRoot, metadataFile)}`] }; + } + if (!(await exists(preparedTextureDirectory))) { + return { + slug, + status: "incomplete", + problems: [`Missing ${path.relative(projectRoot, preparedTextureDirectory)}`], + }; + } + + const metadata = JSON.parse(await readFile(metadataFile, "utf8")); + const visualArtifact = (metadata.assets ?? metadata.artifacts ?? []) + .find((artifact) => artifact.role === "visual"); + const visualFile = path.join(shippingRoot, slug, visualArtifact?.fileName ?? `${slug}-visual.glb`); + if (!(await exists(visualFile))) { + return { slug, status: "incomplete", problems: [`Missing ${path.relative(projectRoot, visualFile)}`] }; + } + + const sourceGlb = await readFile(visualFile); + const { json: gltf, chunks } = parseGlb(sourceGlb, visualFile); + const materialResults = []; + const missingTextures = []; + for (const [materialIndex, material] of (gltf.materials ?? []).entries()) { + const textureIndex = material.pbrMetallicRoughness?.baseColorTexture?.index; + const texture = gltf.textures?.[textureIndex]; + const image = gltf.images?.[sourceImageIndex(texture)]; + if (!image?.name) continue; + + const sourceFile = await resolveSourceTexture(preparedTextureDirectory, image.name); + if (!sourceFile) { + missingTextures.push(`${image.name}.png`); + continue; + } + const expectedRole = await classifyAlpha(sourceFile); + const expectedAlphaMode = expectedRole === "blend" ? "BLEND" : expectedRole === "cutout" ? "MASK" : "OPAQUE"; + const actualAlphaMode = material.alphaMode ?? "OPAQUE"; + const actualRole = material.extras?.semanticRole ?? "missing"; + materialResults.push({ + materialIndex, + material: material.name ?? `material-${materialIndex}`, + texture: image.name, + expectedRole, + expectedAlphaMode, + actualRole, + actualAlphaMode, + matches: expectedAlphaMode === actualAlphaMode && expectedRole === actualRole, + }); + if (fix && (expectedAlphaMode !== actualAlphaMode || expectedRole !== actualRole)) { + if (expectedAlphaMode === "OPAQUE") delete material.alphaMode; + else material.alphaMode = expectedAlphaMode; + if (expectedAlphaMode === "MASK") material.alphaCutoff = 0.5; + else delete material.alphaCutoff; + material.extras = { ...(material.extras ?? {}), semanticRole: expectedRole }; + } + } + + const mismatches = materialResults.filter((result) => !result.matches); + const alphaMaterials = materialResults.filter((result) => result.expectedRole !== "opaque"); + const problems = [ + ...mismatches.map((result) => + `${result.texture}: expected ${result.expectedAlphaMode}/${result.expectedRole}, got ${result.actualAlphaMode}/${result.actualRole}`, + ), + ...[...new Set(missingTextures)].map((file) => `Missing prepared source texture ${file}`), + ]; + if (fix && mismatches.length > 0 && missingTextures.length === 0) { + const visualBuffer = serializeGlb(sourceGlb, gltf, chunks); + await writeFile(visualFile, visualBuffer); + if (visualArtifact) { + await refreshShippingMetadata(metadataFile, metadata, visualArtifact, visualBuffer); + } + } + return { + slug, + status: problems.length === 0 ? "green" : fix && missingTextures.length === 0 ? "repaired" : "mismatch", + materials: materialResults.length, + alphaMaterials: alphaMaterials.map(({ material, texture, expectedAlphaMode }) => ({ + material, + texture, + alphaMode: expectedAlphaMode, + })), + mismatches, + problems, + }; +} + +const recipeFiles = (await readdir(recipeDirectory)) + .filter((file) => file.endsWith(".json") && !file.endsWith("-population.json")) + .sort(); +const recipes = []; +for (const recipeFile of recipeFiles) { + const recipe = JSON.parse(await readFile(path.join(recipeDirectory, recipeFile), "utf8")); + if (!recipe.slug || recipe.slug === "runewaker-player-avatars") continue; + if (requestedSlugs.size > 0 && !requestedSlugs.has(recipe.slug)) continue; + recipes.push(recipe); +} + +const results = []; +for (const recipe of recipes) { + results.push(await auditDungeon(recipe)); +} + +const failures = results.filter((result) => !["green", "repaired"].includes(result.status)); +const report = { + schemaVersion: 1, + dungeonCount: results.length, + status: failures.length === 0 ? fix ? "repaired" : "green" : "mismatch", + failureCount: failures.length, + alphaDungeonCount: results.filter((result) => result.alphaMaterials?.length > 0).length, + results, +}; +console.log(JSON.stringify(report, null, 2)); +if (failures.length > 0) process.exitCode = 1; diff --git a/scripts/runewaker-pipeline/blender/convert-runewaker.py b/scripts/runewaker-pipeline/blender/convert-runewaker.py index 0a5535d1..fa5b93f6 100644 --- a/scripts/runewaker-pipeline/blender/convert-runewaker.py +++ b/scripts/runewaker-pipeline/blender/convert-runewaker.py @@ -6,6 +6,7 @@ Expected invocation: """ from __future__ import annotations +from array import array import json import math import os @@ -111,6 +112,69 @@ def material_image_key(material: bpy.types.Material) -> str: return f"material:{material.name.casefold()}" +def material_image(material: bpy.types.Material) -> bpy.types.Image | None: + if not material.use_nodes or material.node_tree is None: + return None + for node in material.node_tree.nodes: + if node.type == "TEX_IMAGE" and node.image is not None: + return node.image + return None + + +def image_alpha_role(image: bpy.types.Image | None) -> str: + """Classify source alpha without turning fully opaque RGBA textures translucent.""" + if image is None or image.channels < 4: + return "opaque" + pixels = array("f", [0.0]) * len(image.pixels) + image.pixels.foreach_get(pixels) + alpha_values = pixels[3::image.channels] + if min(alpha_values, default=1.0) >= 0.999: + return "opaque" + if any(0.001 < alpha < 0.999 for alpha in alpha_values): + return "blend" + return "cutout" + + +def preserve_material_alpha(material: bpy.types.Material) -> str: + image = material_image(material) + role = image_alpha_role(image) + if role == "opaque" or material.node_tree is None: + return role + + image_node = next( + node + for node in material.node_tree.nodes + if node.type == "TEX_IMAGE" and node.image == image + ) + principled = next( + (node for node in material.node_tree.nodes if node.type == "BSDF_PRINCIPLED"), + None, + ) + if principled is None: + return role + alpha_output = image_node.outputs.get("Alpha") + alpha_input = principled.inputs.get("Alpha") + if alpha_output is None or alpha_input is None: + return role + + if role == "blend": + if not alpha_input.is_linked: + material.node_tree.links.new(alpha_output, alpha_input) + material.blend_method = "BLEND" + if hasattr(material, "surface_render_method"): + material.surface_render_method = "BLENDED" + else: + alpha_clip = material.node_tree.nodes.new("ShaderNodeMath") + alpha_clip.name = "RuneWaker Alpha Clip" + alpha_clip.operation = "GREATER_THAN" + alpha_clip.inputs[1].default_value = 0.5 + material.node_tree.links.new(alpha_output, alpha_clip.inputs[0]) + material.node_tree.links.new(alpha_clip.outputs[0], alpha_input) + material.blend_method = "CLIP" + material.alpha_threshold = 0.5 + return role + + def deduplicate_materials(objects: list[bpy.types.Object]) -> dict[str, int]: canonical: dict[str, bpy.types.Material] = {} replacements = 0 @@ -126,7 +190,7 @@ def deduplicate_materials(objects: list[bpy.types.Object]) -> dict[str, int]: replacements += 1 else: canonical[key] = material - material["semanticRole"] = "opaque" + material["semanticRole"] = preserve_material_alpha(material) material["sourceFormat"] = "RuneWaker DDS" return {"unique": len(canonical), "replacements": replacements} diff --git a/scripts/runewaker-pipeline/build-environment.mjs b/scripts/runewaker-pipeline/build-environment.mjs index f54d5b4c..cdbcd68f 100644 --- a/scripts/runewaker-pipeline/build-environment.mjs +++ b/scripts/runewaker-pipeline/build-environment.mjs @@ -241,6 +241,7 @@ async function cachedResourceAsset(resourceRecord) { throw new Error("source ROS is missing: " + projectPath(sourceFile)); } const sourceSha256 = await sha256(sourceFile); + const converterSha256 = await sha256(blenderScript); const cacheRoot = path.join(resourceCacheDirectory, resourceRecord.cacheKey); const raw = path.join(cacheRoot, "raw"); const prepared = path.join(cacheRoot, "prepared"); @@ -253,6 +254,7 @@ async function cachedResourceAsset(resourceRecord) { .map((file) => path.join(cacheRoot, file)); if (cached.source?.resource === resourceRecord.normalizedResource && cached.source?.sha256 === sourceSha256 + && cached.toolchain?.converterSha256 === converterSha256 && cached.coordinateSystem?.metersPerSourceUnit === recipe.coordinateSystem.metersPerSourceUnit && (await Promise.all(outputs.map(exists))).every(Boolean)) { return { @@ -300,6 +302,9 @@ async function cachedResourceAsset(resourceRecord) { nativeBridge: recipe.coordinateSystem.nativeBridge, metersPerSourceUnit: recipe.coordinateSystem.metersPerSourceUnit, }, + toolchain: { + converterSha256, + }, visual: { file: path.relative( cacheRoot, diff --git a/src/assets/game/dungeons/aeternal-circle/aeternal-circle-visual.glb b/src/assets/game/dungeons/aeternal-circle/aeternal-circle-visual.glb index b200eeda..9e630495 100644 Binary files a/src/assets/game/dungeons/aeternal-circle/aeternal-circle-visual.glb and b/src/assets/game/dungeons/aeternal-circle/aeternal-circle-visual.glb differ diff --git a/src/assets/game/dungeons/aeternal-circle/import-metadata.json b/src/assets/game/dungeons/aeternal-circle/import-metadata.json index f8ef3acb..17ffa454 100644 --- a/src/assets/game/dungeons/aeternal-circle/import-metadata.json +++ b/src/assets/game/dungeons/aeternal-circle/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "aeternal-circle-visual.glb", - "size": 4963912, - "sha256": "42fdcff1b25b58688943a030f69fd14ea839247a7baabbc31b5f6f7faaf49785", + "size": 4963968, + "sha256": "de1ba06b7e3a9237fcff75aff2b00093287975e755cc7e98e77de1b7af6a9c14", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/aeternal-circle/optimization-report.json b/src/assets/game/dungeons/aeternal-circle/optimization-report.json index ffe2bf4c..d947996f 100644 --- a/src/assets/game/dungeons/aeternal-circle/optimization-report.json +++ b/src/assets/game/dungeons/aeternal-circle/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "aeternal-circle-visual.optimized.glb", - "size": 4963912, - "checksum": "42fdcff1b25b58688943a030f69fd14ea839247a7baabbc31b5f6f7faaf49785" + "size": 4963968, + "checksum": "de1ba06b7e3a9237fcff75aff2b00093287975e755cc7e98e77de1b7af6a9c14" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/barren-caves/barren-caves-visual.glb b/src/assets/game/dungeons/barren-caves/barren-caves-visual.glb index 0e6128a3..5f600e85 100644 Binary files a/src/assets/game/dungeons/barren-caves/barren-caves-visual.glb and b/src/assets/game/dungeons/barren-caves/barren-caves-visual.glb differ diff --git a/src/assets/game/dungeons/barren-caves/import-metadata.json b/src/assets/game/dungeons/barren-caves/import-metadata.json index 6b173ef4..3423c70b 100644 --- a/src/assets/game/dungeons/barren-caves/import-metadata.json +++ b/src/assets/game/dungeons/barren-caves/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "barren-caves-visual.glb", - "size": 1710152, - "sha256": "d0f733de4b26c0f432c5e160614765f320bf1753a829be269740b340e7415a12", + "size": 1710188, + "sha256": "e6dc49e6aa6fd20cee1b2afa43324a714f8f8284a6becef8dc514509b432f9bb", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/barren-caves/optimization-report.json b/src/assets/game/dungeons/barren-caves/optimization-report.json index 286f134e..b03a2eb6 100644 --- a/src/assets/game/dungeons/barren-caves/optimization-report.json +++ b/src/assets/game/dungeons/barren-caves/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "barren-caves-visual.optimized.glb", - "size": 1710152, - "checksum": "d0f733de4b26c0f432c5e160614765f320bf1753a829be269740b340e7415a12" + "size": 1710188, + "checksum": "e6dc49e6aa6fd20cee1b2afa43324a714f8f8284a6becef8dc514509b432f9bb" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/cave-water-dragon/cave-water-dragon-visual.glb b/src/assets/game/dungeons/cave-water-dragon/cave-water-dragon-visual.glb index fd6565dd..a77f8fde 100644 Binary files a/src/assets/game/dungeons/cave-water-dragon/cave-water-dragon-visual.glb and b/src/assets/game/dungeons/cave-water-dragon/cave-water-dragon-visual.glb differ diff --git a/src/assets/game/dungeons/cave-water-dragon/import-metadata.json b/src/assets/game/dungeons/cave-water-dragon/import-metadata.json index fc329f0f..fd34e5dc 100644 --- a/src/assets/game/dungeons/cave-water-dragon/import-metadata.json +++ b/src/assets/game/dungeons/cave-water-dragon/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "cave-water-dragon-visual.glb", - "size": 8893112, - "sha256": "cc57c0285de77690c4a1cdabaecd8b192791cbd05aca38aa21ed96462688ca31", + "size": 8893204, + "sha256": "dd1887f88d6ee9102da0e34ea2b4087742e37a2b8ce779c722bc124effe1faca", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/cave-water-dragon/optimization-report.json b/src/assets/game/dungeons/cave-water-dragon/optimization-report.json index 63e6d2e9..f21909e2 100644 --- a/src/assets/game/dungeons/cave-water-dragon/optimization-report.json +++ b/src/assets/game/dungeons/cave-water-dragon/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "cave-water-dragon-visual.optimized.glb", - "size": 8893112, - "checksum": "cc57c0285de77690c4a1cdabaecd8b192791cbd05aca38aa21ed96462688ca31" + "size": 8893204, + "checksum": "dd1887f88d6ee9102da0e34ea2b4087742e37a2b8ce779c722bc124effe1faca" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/demon-stronghold-125/demon-stronghold-125-visual.glb b/src/assets/game/dungeons/demon-stronghold-125/demon-stronghold-125-visual.glb index a89d0d2e..23a6f690 100644 Binary files a/src/assets/game/dungeons/demon-stronghold-125/demon-stronghold-125-visual.glb and b/src/assets/game/dungeons/demon-stronghold-125/demon-stronghold-125-visual.glb differ diff --git a/src/assets/game/dungeons/demon-stronghold-125/import-metadata.json b/src/assets/game/dungeons/demon-stronghold-125/import-metadata.json index 21b728b2..456e996f 100644 --- a/src/assets/game/dungeons/demon-stronghold-125/import-metadata.json +++ b/src/assets/game/dungeons/demon-stronghold-125/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "demon-stronghold-125-visual.glb", - "size": 15134500, - "sha256": "ecbacfe274e7eecb104c5807eedf3cc51cd4b7a628967c6331fc93b86396b842", + "size": 15135060, + "sha256": "e2106115dcc216999d2e37d26eeb52844ce4f41b44e19594699f37a54d7f19b9", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/demon-stronghold-125/optimization-report.json b/src/assets/game/dungeons/demon-stronghold-125/optimization-report.json index 66be99b5..4414ff1f 100644 --- a/src/assets/game/dungeons/demon-stronghold-125/optimization-report.json +++ b/src/assets/game/dungeons/demon-stronghold-125/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "demon-stronghold-125-visual.optimized.glb", - "size": 15134500, - "checksum": "ecbacfe274e7eecb104c5807eedf3cc51cd4b7a628967c6331fc93b86396b842" + "size": 15135060, + "checksum": "e2106115dcc216999d2e37d26eeb52844ce4f41b44e19594699f37a54d7f19b9" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/demon-stronghold/demon-stronghold-visual.glb b/src/assets/game/dungeons/demon-stronghold/demon-stronghold-visual.glb index b13dadc8..2de9f8b2 100644 Binary files a/src/assets/game/dungeons/demon-stronghold/demon-stronghold-visual.glb and b/src/assets/game/dungeons/demon-stronghold/demon-stronghold-visual.glb differ diff --git a/src/assets/game/dungeons/demon-stronghold/import-metadata.json b/src/assets/game/dungeons/demon-stronghold/import-metadata.json index b675b264..e37c6a82 100644 --- a/src/assets/game/dungeons/demon-stronghold/import-metadata.json +++ b/src/assets/game/dungeons/demon-stronghold/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "demon-stronghold-visual.glb", - "size": 15166984, - "sha256": "9368984b3c183a8db6e8b7e18b7818edcf41b8edd45ffeca1d1df28aadc592a2", + "size": 15167544, + "sha256": "664181dc790b9e68cdc7451dbd0b1170a033a71ca19375743b6e66ca25889723", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/demon-stronghold/optimization-report.json b/src/assets/game/dungeons/demon-stronghold/optimization-report.json index 99bb2f3a..d7264343 100644 --- a/src/assets/game/dungeons/demon-stronghold/optimization-report.json +++ b/src/assets/game/dungeons/demon-stronghold/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "demon-stronghold-visual.optimized.glb", - "size": 15166984, - "checksum": "9368984b3c183a8db6e8b7e18b7818edcf41b8edd45ffeca1d1df28aadc592a2" + "size": 15167544, + "checksum": "664181dc790b9e68cdc7451dbd0b1170a033a71ca19375743b6e66ca25889723" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/dungeon-of-dalanis/dungeon-of-dalanis-visual.glb b/src/assets/game/dungeons/dungeon-of-dalanis/dungeon-of-dalanis-visual.glb index eb937ddd..856846ca 100644 Binary files a/src/assets/game/dungeons/dungeon-of-dalanis/dungeon-of-dalanis-visual.glb and b/src/assets/game/dungeons/dungeon-of-dalanis/dungeon-of-dalanis-visual.glb differ diff --git a/src/assets/game/dungeons/dungeon-of-dalanis/import-metadata.json b/src/assets/game/dungeons/dungeon-of-dalanis/import-metadata.json index a50b7ae9..cc10b229 100644 --- a/src/assets/game/dungeons/dungeon-of-dalanis/import-metadata.json +++ b/src/assets/game/dungeons/dungeon-of-dalanis/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "dungeon-of-dalanis-visual.glb", - "size": 11936864, - "sha256": "4c584ef34779e516f8e6ff3a4907eaab1c7c8d49d4c302de66e17b09222d8829", + "size": 11937088, + "sha256": "45cad819bc348dec8926f7de237c625e9d18697b4200804e0440dd16b8976959", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/dungeon-of-dalanis/optimization-report.json b/src/assets/game/dungeons/dungeon-of-dalanis/optimization-report.json index 8a01bdf6..6ba974f6 100644 --- a/src/assets/game/dungeons/dungeon-of-dalanis/optimization-report.json +++ b/src/assets/game/dungeons/dungeon-of-dalanis/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "dungeon-of-dalanis-visual.optimized.glb", - "size": 11936864, - "checksum": "4c584ef34779e516f8e6ff3a4907eaab1c7c8d49d4c302de66e17b09222d8829" + "size": 11937088, + "checksum": "45cad819bc348dec8926f7de237c625e9d18697b4200804e0440dd16b8976959" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/forsaken-abbey/forsaken-abbey-visual.glb b/src/assets/game/dungeons/forsaken-abbey/forsaken-abbey-visual.glb index bbff8a21..24b93e77 100644 Binary files a/src/assets/game/dungeons/forsaken-abbey/forsaken-abbey-visual.glb and b/src/assets/game/dungeons/forsaken-abbey/forsaken-abbey-visual.glb differ diff --git a/src/assets/game/dungeons/forsaken-abbey/import-metadata.json b/src/assets/game/dungeons/forsaken-abbey/import-metadata.json index 17294fbc..1aece6e0 100644 --- a/src/assets/game/dungeons/forsaken-abbey/import-metadata.json +++ b/src/assets/game/dungeons/forsaken-abbey/import-metadata.json @@ -284,8 +284,8 @@ { "role": "visual", "fileName": "forsaken-abbey-visual.glb", - "size": 9580212, - "sha256": "37800c26cdf784658a57dcc7328ab1b9b23e46d018be6d1e6420f127768b0332", + "size": 9580568, + "sha256": "ee7afd5e9179e6bcb7ec9171798466282e09d4ef696a3f9639bcd2bbc2ae8f48", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/forsaken-abbey/optimization-report.json b/src/assets/game/dungeons/forsaken-abbey/optimization-report.json index b4a2008a..759f40fb 100644 --- a/src/assets/game/dungeons/forsaken-abbey/optimization-report.json +++ b/src/assets/game/dungeons/forsaken-abbey/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "forsaken-abbey-visual.optimized.glb", - "size": 9580212, - "checksum": "37800c26cdf784658a57dcc7328ab1b9b23e46d018be6d1e6420f127768b0332" + "size": 9580568, + "checksum": "ee7afd5e9179e6bcb7ec9171798466282e09d4ef696a3f9639bcd2bbc2ae8f48" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/grafu-castle/grafu-castle-visual.glb b/src/assets/game/dungeons/grafu-castle/grafu-castle-visual.glb index 8561b3c6..459cbcf3 100644 Binary files a/src/assets/game/dungeons/grafu-castle/grafu-castle-visual.glb and b/src/assets/game/dungeons/grafu-castle/grafu-castle-visual.glb differ diff --git a/src/assets/game/dungeons/grafu-castle/import-metadata.json b/src/assets/game/dungeons/grafu-castle/import-metadata.json index fe15349b..836e8bf3 100644 --- a/src/assets/game/dungeons/grafu-castle/import-metadata.json +++ b/src/assets/game/dungeons/grafu-castle/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "grafu-castle-visual.glb", - "size": 11149716, - "sha256": "ba4552484ef9cddc1c00607d0dfd4c8fb0bae5b3cd3f992d215a580dac1e96ed", + "size": 11150124, + "sha256": "b775778f457d639e3f21982aaa6bd3ee1b0eba951c711d1ba319b0588e24a38f", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/grafu-castle/optimization-report.json b/src/assets/game/dungeons/grafu-castle/optimization-report.json index bf2822b4..38fed982 100644 --- a/src/assets/game/dungeons/grafu-castle/optimization-report.json +++ b/src/assets/game/dungeons/grafu-castle/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "grafu-castle-visual.optimized.glb", - "size": 11149716, - "checksum": "ba4552484ef9cddc1c00607d0dfd4c8fb0bae5b3cd3f992d215a580dac1e96ed" + "size": 11150124, + "checksum": "b775778f457d639e3f21982aaa6bd3ee1b0eba951c711d1ba319b0588e24a38f" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/hall-of-survivors/hall-of-survivors-visual.glb b/src/assets/game/dungeons/hall-of-survivors/hall-of-survivors-visual.glb index 8979692c..1952093b 100644 Binary files a/src/assets/game/dungeons/hall-of-survivors/hall-of-survivors-visual.glb and b/src/assets/game/dungeons/hall-of-survivors/hall-of-survivors-visual.glb differ diff --git a/src/assets/game/dungeons/hall-of-survivors/import-metadata.json b/src/assets/game/dungeons/hall-of-survivors/import-metadata.json index 7a9e52f5..3e3d1ef4 100644 --- a/src/assets/game/dungeons/hall-of-survivors/import-metadata.json +++ b/src/assets/game/dungeons/hall-of-survivors/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "hall-of-survivors-visual.glb", - "size": 15216768, - "sha256": "18b9d116da599159719ad76eeee45a4d89056ee8c61cc4f21cab9a1d9228fff7", + "size": 15216936, + "sha256": "96b91b24462b8428e8808295dcea29d26de9be8431ea6667dde92cdded4ddf2a", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/hall-of-survivors/optimization-report.json b/src/assets/game/dungeons/hall-of-survivors/optimization-report.json index b765cd59..295167a6 100644 --- a/src/assets/game/dungeons/hall-of-survivors/optimization-report.json +++ b/src/assets/game/dungeons/hall-of-survivors/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "hall-of-survivors-visual.optimized.glb", - "size": 15216768, - "checksum": "18b9d116da599159719ad76eeee45a4d89056ee8c61cc4f21cab9a1d9228fff7" + "size": 15216936, + "checksum": "96b91b24462b8428e8808295dcea29d26de9be8431ea6667dde92cdded4ddf2a" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/hoto/hoto-navigation.glb b/src/assets/game/dungeons/hoto/hoto-navigation.glb index 7427882d..7a822987 100644 Binary files a/src/assets/game/dungeons/hoto/hoto-navigation.glb and b/src/assets/game/dungeons/hoto/hoto-navigation.glb differ diff --git a/src/assets/game/dungeons/hoto/hoto-preview.png b/src/assets/game/dungeons/hoto/hoto-preview.png index 73a9864e..76c8693d 100644 Binary files a/src/assets/game/dungeons/hoto/hoto-preview.png and b/src/assets/game/dungeons/hoto/hoto-preview.png differ diff --git a/src/assets/game/dungeons/hoto/hoto-visual.glb b/src/assets/game/dungeons/hoto/hoto-visual.glb index 89dcd90b..7b29133b 100644 Binary files a/src/assets/game/dungeons/hoto/hoto-visual.glb and b/src/assets/game/dungeons/hoto/hoto-visual.glb differ diff --git a/src/assets/game/dungeons/hoto/import-metadata.json b/src/assets/game/dungeons/hoto/import-metadata.json index 6b7974d9..f96fc04a 100644 --- a/src/assets/game/dungeons/hoto/import-metadata.json +++ b/src/assets/game/dungeons/hoto/import-metadata.json @@ -3,7 +3,7 @@ "dungeonId": "hoto", "title": "HOTO", "status": "source-packaged", - "generatedAt": "2026-08-01T01:00:34.655Z", + "generatedAt": "2026-09-09T19:39:49.407Z", "source": { "recipe": "scripts/runewaker-pipeline/recipes/hoto.json", "wdb": "Resource/wdb/dungeon/dgn_echoes_of_the_sea.wdb", @@ -261,33 +261,37 @@ { "role": "visual", "fileName": "hoto-visual.glb", - "size": 6667208, - "sha256": "0d12b9c6dc477704d2833c81f76113d340da7f07f5885a40bafd1321524e455e", - "compression": "meshopt+ktx2" + "relativeFile": "hoto-visual.glb", + "size": 6710184, + "sha256": "beff3a180c55166d5ebd7e3610e65b59f8104edfd7596aa22ac3a49c578fcdc0" }, { "role": "collision", "fileName": "hoto-collision-000.glb", + "relativeFile": "hoto-collision-000.glb", "size": 3088036, "sha256": "3116529f2d262ff4cab7997e852f9d15caacab6298921360b49dcafd52b8c475" }, { "role": "collision", "fileName": "hoto-collision-001.glb", + "relativeFile": "hoto-collision-001.glb", "size": 2989324, "sha256": "1f5cbd4b04b5a060029fd7c023ee19db5d8646566fcd01b931473b5b13c13b9f" }, { "role": "collision", "fileName": "hoto-collision-002.glb", + "relativeFile": "hoto-collision-002.glb", "size": 526648, "sha256": "0a468a6ef30941c04a20456866f0593f7e1f9f9bf8dc0024176d2128975f1879" }, { "role": "navigation", "fileName": "hoto-navigation.glb", + "relativeFile": "hoto-navigation.glb", "size": 482260, - "sha256": "2cd78e6d8f52cb6488747a1f87cb688eedb77ccf412fb50674c25f70306477fd" + "sha256": "a2bde2e4b8ed0d0bac8c4669a05192ff0a35ae9c64df0c0a29fe3735b65cbf4b" } ], "review": { @@ -300,6 +304,6 @@ "exceptions": [ "Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.", "Collision initially includes decorative primary-ROS geometry.", - "Original skeletal animations and complex scripted mechanics remain later passes." + "Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work." ] } diff --git a/src/assets/game/dungeons/hoto/optimization-report.json b/src/assets/game/dungeons/hoto/optimization-report.json index 7e6471bb..e963a67c 100644 --- a/src/assets/game/dungeons/hoto/optimization-report.json +++ b/src/assets/game/dungeons/hoto/optimization-report.json @@ -4,15 +4,11 @@ "status": "review-required", "source": { "file": "hoto-visual.glb", - "size": 26313948 + "size": 26314004 }, "optimized": { "file": "hoto-visual.optimized.glb", - "size": 6667208, - "checksum": "0d12b9c6dc477704d2833c81f76113d340da7f07f5885a40bafd1321524e455e" + "size": 6710184 }, - "compression": "meshopt+ktx2", - "commands": [ - "node scripts/asset-pipeline/compress-ktx2.mjs --concurrency 1 --jobs 2" - ] + "compression": "meshopt+ktx2" } diff --git a/src/assets/game/dungeons/hoto/wdb-report.json b/src/assets/game/dungeons/hoto/wdb-report.json index d2183b6a..d951fff4 100644 --- a/src/assets/game/dungeons/hoto/wdb-report.json +++ b/src/assets/game/dungeons/hoto/wdb-report.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceWdb": "D:\\Projects\\rom-pvt-server-backups\\Runewaker\\Resource\\wdb\\dungeon\\dgn_echoes_of_the_sea.wdb", + "sourceWdb": "F:\\Projects\\rom-pvt-server-backups\\Runewaker\\Resource\\wdb\\dungeon\\dgn_echoes_of_the_sea.wdb", "coordinateSystem": "runewaker-left-handed-y-up", "descriptorCount": 1627, "descriptors": [ diff --git a/src/assets/game/dungeons/ice-dwarf-kingdom/ice-dwarf-kingdom-visual.glb b/src/assets/game/dungeons/ice-dwarf-kingdom/ice-dwarf-kingdom-visual.glb index 5133c330..7adcc02f 100644 Binary files a/src/assets/game/dungeons/ice-dwarf-kingdom/ice-dwarf-kingdom-visual.glb and b/src/assets/game/dungeons/ice-dwarf-kingdom/ice-dwarf-kingdom-visual.glb differ diff --git a/src/assets/game/dungeons/ice-dwarf-kingdom/import-metadata.json b/src/assets/game/dungeons/ice-dwarf-kingdom/import-metadata.json index 5f62212f..9272594c 100644 --- a/src/assets/game/dungeons/ice-dwarf-kingdom/import-metadata.json +++ b/src/assets/game/dungeons/ice-dwarf-kingdom/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "ice-dwarf-kingdom-visual.glb", - "size": 9880192, - "sha256": "a6b902e85bb6026f18281d77efa235ec880ffc2fc13ea6bfdc431d24af768e00", + "size": 9880304, + "sha256": "41cef8ce1bf6dbf7df0c4e2797a325e4d50a3870d435d46684600894d4aa92e7", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/ice-dwarf-kingdom/optimization-report.json b/src/assets/game/dungeons/ice-dwarf-kingdom/optimization-report.json index 77fb86db..708343a9 100644 --- a/src/assets/game/dungeons/ice-dwarf-kingdom/optimization-report.json +++ b/src/assets/game/dungeons/ice-dwarf-kingdom/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "ice-dwarf-kingdom-visual.optimized.glb", - "size": 9880192, - "checksum": "a6b902e85bb6026f18281d77efa235ec880ffc2fc13ea6bfdc431d24af768e00" + "size": 9880304, + "checksum": "41cef8ce1bf6dbf7df0c4e2797a325e4d50a3870d435d46684600894d4aa92e7" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/kal-turok-hive/import-metadata.json b/src/assets/game/dungeons/kal-turok-hive/import-metadata.json index 659810e4..2e0a8d6b 100644 --- a/src/assets/game/dungeons/kal-turok-hive/import-metadata.json +++ b/src/assets/game/dungeons/kal-turok-hive/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "kal-turok-hive-visual.glb", - "size": 1777616, - "sha256": "012867deefc4dc5a18c3e1a507309aa48f119cf4c2e88968fdd2787936ed7c24", + "size": 1777728, + "sha256": "75c0c596218cec07003e2def1d74c8e1f5ca3010ed370db558a1786b003f4f1c", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/kal-turok-hive/kal-turok-hive-visual.glb b/src/assets/game/dungeons/kal-turok-hive/kal-turok-hive-visual.glb index a88c9f4d..d6f2c96b 100644 Binary files a/src/assets/game/dungeons/kal-turok-hive/kal-turok-hive-visual.glb and b/src/assets/game/dungeons/kal-turok-hive/kal-turok-hive-visual.glb differ diff --git a/src/assets/game/dungeons/kal-turok-hive/optimization-report.json b/src/assets/game/dungeons/kal-turok-hive/optimization-report.json index bcde1e1e..507b2895 100644 --- a/src/assets/game/dungeons/kal-turok-hive/optimization-report.json +++ b/src/assets/game/dungeons/kal-turok-hive/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "kal-turok-hive-visual.optimized.glb", - "size": 1777616, - "checksum": "012867deefc4dc5a18c3e1a507309aa48f119cf4c2e88968fdd2787936ed7c24" + "size": 1777728, + "checksum": "75c0c596218cec07003e2def1d74c8e1f5ca3010ed370db558a1786b003f4f1c" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/kalins-shrine/import-metadata.json b/src/assets/game/dungeons/kalins-shrine/import-metadata.json index 7b0ed383..785ea81f 100644 --- a/src/assets/game/dungeons/kalins-shrine/import-metadata.json +++ b/src/assets/game/dungeons/kalins-shrine/import-metadata.json @@ -3,7 +3,7 @@ "dungeonId": "kalins-shrine", "title": "Kalin's Shrine", "status": "source-packaged", - "generatedAt": "2026-08-01T00:57:43.432Z", + "generatedAt": "2026-09-09T18:51:01.674Z", "source": { "recipe": "scripts/runewaker-pipeline/recipes/kalins-shrine.json", "wdb": "Resource/wdb/dungeon/dgn_kleeon_temple.wdb", @@ -277,33 +277,37 @@ { "role": "visual", "fileName": "kalins-shrine-visual.glb", - "size": 8747072, - "sha256": "769accb48e23c9f80e28697e3e8454ef4148243a69ce247ed06ba4eb35bb0da7", - "compression": "meshopt+ktx2" + "relativeFile": "kalins-shrine-visual.glb", + "size": 8851724, + "sha256": "023c43a56253e97fa0526185c61f76e0e9fa8398b7a35771728566c331cd22a9" }, { "role": "collision", "fileName": "kalins-shrine-collision-000.glb", + "relativeFile": "kalins-shrine-collision-000.glb", "size": 4748976, "sha256": "ed1e713c159e024b4786ef8befadbfedd3849ba580e7f49970e2785b6f4fff7d" }, { "role": "collision", "fileName": "kalins-shrine-collision-001.glb", + "relativeFile": "kalins-shrine-collision-001.glb", "size": 4555800, "sha256": "5fea14212353593c8daced2e303f88dae010f8f73aedb9890a3eee67eb8dd7cd" }, { "role": "collision", "fileName": "kalins-shrine-collision-002.glb", + "relativeFile": "kalins-shrine-collision-002.glb", "size": 1215728, "sha256": "e51d48d1f4521f1a4722fd4c528a90bdfd2978a87d2ace0dd662f23475490565" }, { "role": "navigation", "fileName": "kalins-shrine-navigation.glb", + "relativeFile": "kalins-shrine-navigation.glb", "size": 327792, - "sha256": "37fd7f8efac36f83279efbfb33df37646453af214db29597bd14c8e9f19ddc04" + "sha256": "6b77496f7b46890d401f955f9254ae37d25d9a1287a3bc9e787e9a254f9d9071" } ], "review": { @@ -316,6 +320,6 @@ "exceptions": [ "Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.", "Collision initially includes decorative primary-ROS geometry.", - "Original skeletal animations and complex scripted mechanics remain later passes." + "Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work." ] } diff --git a/src/assets/game/dungeons/kalins-shrine/kalins-shrine-navigation.glb b/src/assets/game/dungeons/kalins-shrine/kalins-shrine-navigation.glb index 54e2ae89..146d1082 100644 Binary files a/src/assets/game/dungeons/kalins-shrine/kalins-shrine-navigation.glb and b/src/assets/game/dungeons/kalins-shrine/kalins-shrine-navigation.glb differ diff --git a/src/assets/game/dungeons/kalins-shrine/kalins-shrine-preview.png b/src/assets/game/dungeons/kalins-shrine/kalins-shrine-preview.png index 7601e49e..3015a044 100644 Binary files a/src/assets/game/dungeons/kalins-shrine/kalins-shrine-preview.png and b/src/assets/game/dungeons/kalins-shrine/kalins-shrine-preview.png differ diff --git a/src/assets/game/dungeons/kalins-shrine/kalins-shrine-visual.glb b/src/assets/game/dungeons/kalins-shrine/kalins-shrine-visual.glb index e4f15698..c7a93ae4 100644 Binary files a/src/assets/game/dungeons/kalins-shrine/kalins-shrine-visual.glb and b/src/assets/game/dungeons/kalins-shrine/kalins-shrine-visual.glb differ diff --git a/src/assets/game/dungeons/kalins-shrine/optimization-report.json b/src/assets/game/dungeons/kalins-shrine/optimization-report.json index c47440e5..ce333b60 100644 --- a/src/assets/game/dungeons/kalins-shrine/optimization-report.json +++ b/src/assets/game/dungeons/kalins-shrine/optimization-report.json @@ -4,15 +4,11 @@ "status": "review-required", "source": { "file": "kalins-shrine-visual.glb", - "size": 26551164 + "size": 26551296 }, "optimized": { "file": "kalins-shrine-visual.optimized.glb", - "size": 8747072, - "checksum": "769accb48e23c9f80e28697e3e8454ef4148243a69ce247ed06ba4eb35bb0da7" + "size": 8851724 }, - "compression": "meshopt+ktx2", - "commands": [ - "node scripts/asset-pipeline/compress-ktx2.mjs --concurrency 1 --jobs 2" - ] + "compression": "meshopt+ktx2" } diff --git a/src/assets/game/dungeons/kalins-shrine/wdb-report.json b/src/assets/game/dungeons/kalins-shrine/wdb-report.json index ec14b386..553b148e 100644 --- a/src/assets/game/dungeons/kalins-shrine/wdb-report.json +++ b/src/assets/game/dungeons/kalins-shrine/wdb-report.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceWdb": "D:\\Projects\\rom-pvt-server-backups\\Runewaker\\Resource\\wdb\\dungeon\\dgn_kleeon_temple.wdb", + "sourceWdb": "F:\\Projects\\rom-pvt-server-backups\\Runewaker\\Resource\\wdb\\dungeon\\dgn_kleeon_temple.wdb", "coordinateSystem": "runewaker-left-handed-y-up", "descriptorCount": 943, "descriptors": [ diff --git a/src/assets/game/dungeons/kawaks-tomb/import-metadata.json b/src/assets/game/dungeons/kawaks-tomb/import-metadata.json index b800137d..926be8ab 100644 --- a/src/assets/game/dungeons/kawaks-tomb/import-metadata.json +++ b/src/assets/game/dungeons/kawaks-tomb/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "kawaks-tomb-visual.glb", - "size": 8963188, - "sha256": "de54d223725e2c3dfb0c47a97c208a212ef9423860bd0ac390f1482b302d3a32", + "size": 8963376, + "sha256": "3f1755780540c54a1ba9b528e646ebd39b12ab127e41fa2d497fd7749d202018", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/kawaks-tomb/kawaks-tomb-visual.glb b/src/assets/game/dungeons/kawaks-tomb/kawaks-tomb-visual.glb index f72529c4..4a1b325b 100644 Binary files a/src/assets/game/dungeons/kawaks-tomb/kawaks-tomb-visual.glb and b/src/assets/game/dungeons/kawaks-tomb/kawaks-tomb-visual.glb differ diff --git a/src/assets/game/dungeons/kawaks-tomb/optimization-report.json b/src/assets/game/dungeons/kawaks-tomb/optimization-report.json index d3e48c88..b3f59785 100644 --- a/src/assets/game/dungeons/kawaks-tomb/optimization-report.json +++ b/src/assets/game/dungeons/kawaks-tomb/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "kawaks-tomb-visual.optimized.glb", - "size": 8963188, - "checksum": "de54d223725e2c3dfb0c47a97c208a212ef9423860bd0ac390f1482b302d3a32" + "size": 8963376, + "checksum": "3f1755780540c54a1ba9b528e646ebd39b12ab127e41fa2d497fd7749d202018" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/kulech-bones-nest/import-metadata.json b/src/assets/game/dungeons/kulech-bones-nest/import-metadata.json index 208fed61..0ed4d2ab 100644 --- a/src/assets/game/dungeons/kulech-bones-nest/import-metadata.json +++ b/src/assets/game/dungeons/kulech-bones-nest/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "kulech-bones-nest-visual.glb", - "size": 12146892, - "sha256": "2d912dcf7c0c8e59e36362fd947df1d5554c5dd5060590e1abb112e4c39f606f", + "size": 12147008, + "sha256": "b06b42b3c7f19abf9d645eec6be78f868cf6966e0b472e2ca4a1c36d3cf4d2d1", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/kulech-bones-nest/kulech-bones-nest-visual.glb b/src/assets/game/dungeons/kulech-bones-nest/kulech-bones-nest-visual.glb index 1601133e..0f4db36f 100644 Binary files a/src/assets/game/dungeons/kulech-bones-nest/kulech-bones-nest-visual.glb and b/src/assets/game/dungeons/kulech-bones-nest/kulech-bones-nest-visual.glb differ diff --git a/src/assets/game/dungeons/kulech-bones-nest/optimization-report.json b/src/assets/game/dungeons/kulech-bones-nest/optimization-report.json index 58fe1d73..07290c8a 100644 --- a/src/assets/game/dungeons/kulech-bones-nest/optimization-report.json +++ b/src/assets/game/dungeons/kulech-bones-nest/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "kulech-bones-nest-visual.optimized.glb", - "size": 12146892, - "checksum": "2d912dcf7c0c8e59e36362fd947df1d5554c5dd5060590e1abb112e4c39f606f" + "size": 12147008, + "checksum": "b06b42b3c7f19abf9d645eec6be78f868cf6966e0b472e2ca4a1c36d3cf4d2d1" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/lair-demon-dragon/import-metadata.json b/src/assets/game/dungeons/lair-demon-dragon/import-metadata.json index dddab983..228aabb6 100644 --- a/src/assets/game/dungeons/lair-demon-dragon/import-metadata.json +++ b/src/assets/game/dungeons/lair-demon-dragon/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "lair-demon-dragon-visual.glb", - "size": 4551300, - "sha256": "d28481504802203e800985836f2b0907d67f6d6d0495c67500914e749d771ffe", + "size": 4551356, + "sha256": "b371465e96ef978915c9a515846ecf9dccb98607fd549617a1c71f45468fa5a7", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/lair-demon-dragon/lair-demon-dragon-visual.glb b/src/assets/game/dungeons/lair-demon-dragon/lair-demon-dragon-visual.glb index f3d30d05..1f8f32b5 100644 Binary files a/src/assets/game/dungeons/lair-demon-dragon/lair-demon-dragon-visual.glb and b/src/assets/game/dungeons/lair-demon-dragon/lair-demon-dragon-visual.glb differ diff --git a/src/assets/game/dungeons/lair-demon-dragon/optimization-report.json b/src/assets/game/dungeons/lair-demon-dragon/optimization-report.json index 8e91dae3..989f9696 100644 --- a/src/assets/game/dungeons/lair-demon-dragon/optimization-report.json +++ b/src/assets/game/dungeons/lair-demon-dragon/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "lair-demon-dragon-visual.optimized.glb", - "size": 4551300, - "checksum": "d28481504802203e800985836f2b0907d67f6d6d0495c67500914e749d771ffe" + "size": 4551356, + "checksum": "b371465e96ef978915c9a515846ecf9dccb98607fd549617a1c71f45468fa5a7" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/mystic-altar/import-metadata.json b/src/assets/game/dungeons/mystic-altar/import-metadata.json index 7b1abd6f..255ad351 100644 --- a/src/assets/game/dungeons/mystic-altar/import-metadata.json +++ b/src/assets/game/dungeons/mystic-altar/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "mystic-altar-visual.glb", - "size": 5552872, - "sha256": "765dd1b15bbe79fe1ffc2104e10caece19acaf86c919f0327419cb3609dbf600", + "size": 5552892, + "sha256": "c37f27643598c8fe6401828be2950bc08f299dfe491df37736fda4662c70a96e", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/mystic-altar/mystic-altar-visual.glb b/src/assets/game/dungeons/mystic-altar/mystic-altar-visual.glb index 971536a7..0ebf47e6 100644 Binary files a/src/assets/game/dungeons/mystic-altar/mystic-altar-visual.glb and b/src/assets/game/dungeons/mystic-altar/mystic-altar-visual.glb differ diff --git a/src/assets/game/dungeons/mystic-altar/optimization-report.json b/src/assets/game/dungeons/mystic-altar/optimization-report.json index daa700a6..a48221e3 100644 --- a/src/assets/game/dungeons/mystic-altar/optimization-report.json +++ b/src/assets/game/dungeons/mystic-altar/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "mystic-altar-visual.optimized.glb", - "size": 5552872, - "checksum": "765dd1b15bbe79fe1ffc2104e10caece19acaf86c919f0327419cb3609dbf600" + "size": 5552892, + "checksum": "c37f27643598c8fe6401828be2950bc08f299dfe491df37736fda4662c70a96e" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/paspers-shrine/import-metadata.json b/src/assets/game/dungeons/paspers-shrine/import-metadata.json index 198d11ef..0115da78 100644 --- a/src/assets/game/dungeons/paspers-shrine/import-metadata.json +++ b/src/assets/game/dungeons/paspers-shrine/import-metadata.json @@ -3,7 +3,7 @@ "dungeonId": "paspers-shrine", "title": "Pasper's Shrine", "status": "source-packaged", - "generatedAt": "2026-08-01T01:00:57.399Z", + "generatedAt": "2026-09-09T19:19:45.719Z", "source": { "recipe": "scripts/runewaker-pipeline/recipes/paspers-shrine.json", "wdb": "Resource/wdb/dungeon/dgn_borsburs.wdb", @@ -137,27 +137,30 @@ { "role": "visual", "fileName": "paspers-shrine-visual.glb", - "size": 5625952, - "sha256": "82ea6cafd19c0a323c994795699223a07193807da29b300eb12dac41146f1034", - "compression": "meshopt+ktx2" + "relativeFile": "paspers-shrine-visual.glb", + "size": 5674908, + "sha256": "bcf6443e8adb8b7f7dd81f4a97b79aac1c99756a83289bbada31853d2d9adb7c" }, { "role": "collision", "fileName": "paspers-shrine-collision-000.glb", + "relativeFile": "paspers-shrine-collision-000.glb", "size": 4291972, "sha256": "b6efbe1b90545dd2c2af3bfc7396616147211f6f79b999ccf81ca3680fdc984e" }, { "role": "collision", "fileName": "paspers-shrine-collision-001.glb", + "relativeFile": "paspers-shrine-collision-001.glb", "size": 4479896, "sha256": "1c6f52d7e21171acfbe3fb43335b2c7f3de13217881840947f15fd0722adb4de" }, { "role": "navigation", "fileName": "paspers-shrine-navigation.glb", + "relativeFile": "paspers-shrine-navigation.glb", "size": 152356, - "sha256": "e05fb0b7a3c86bba3ac46606365169dea6c575281b2bb15707addcedb279f108" + "sha256": "4ebba3fb97a99df49c7301dbfcdb8154634cf4e56a85c5ed587c7515221d0a10" } ], "review": { @@ -170,6 +173,6 @@ "exceptions": [ "Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.", "Collision initially includes decorative primary-ROS geometry.", - "Original skeletal animations and complex scripted mechanics remain later passes." + "Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work." ] } diff --git a/src/assets/game/dungeons/paspers-shrine/optimization-report.json b/src/assets/game/dungeons/paspers-shrine/optimization-report.json index 384c421b..cf32ab15 100644 --- a/src/assets/game/dungeons/paspers-shrine/optimization-report.json +++ b/src/assets/game/dungeons/paspers-shrine/optimization-report.json @@ -4,15 +4,11 @@ "status": "review-required", "source": { "file": "paspers-shrine-visual.glb", - "size": 14723576 + "size": 14723652 }, "optimized": { "file": "paspers-shrine-visual.optimized.glb", - "size": 5625952, - "checksum": "82ea6cafd19c0a323c994795699223a07193807da29b300eb12dac41146f1034" + "size": 5674908 }, - "compression": "meshopt+ktx2", - "commands": [ - "node scripts/asset-pipeline/compress-ktx2.mjs --concurrency 1 --jobs 2" - ] + "compression": "meshopt+ktx2" } diff --git a/src/assets/game/dungeons/paspers-shrine/paspers-shrine-navigation.glb b/src/assets/game/dungeons/paspers-shrine/paspers-shrine-navigation.glb index d731685e..2073e6e6 100644 Binary files a/src/assets/game/dungeons/paspers-shrine/paspers-shrine-navigation.glb and b/src/assets/game/dungeons/paspers-shrine/paspers-shrine-navigation.glb differ diff --git a/src/assets/game/dungeons/paspers-shrine/paspers-shrine-preview.png b/src/assets/game/dungeons/paspers-shrine/paspers-shrine-preview.png index 23ddacb8..d02791fb 100644 Binary files a/src/assets/game/dungeons/paspers-shrine/paspers-shrine-preview.png and b/src/assets/game/dungeons/paspers-shrine/paspers-shrine-preview.png differ diff --git a/src/assets/game/dungeons/paspers-shrine/paspers-shrine-visual.glb b/src/assets/game/dungeons/paspers-shrine/paspers-shrine-visual.glb index cc724cb0..b59ae81e 100644 Binary files a/src/assets/game/dungeons/paspers-shrine/paspers-shrine-visual.glb and b/src/assets/game/dungeons/paspers-shrine/paspers-shrine-visual.glb differ diff --git a/src/assets/game/dungeons/paspers-shrine/wdb-report.json b/src/assets/game/dungeons/paspers-shrine/wdb-report.json index 66a1947f..504923d8 100644 --- a/src/assets/game/dungeons/paspers-shrine/wdb-report.json +++ b/src/assets/game/dungeons/paspers-shrine/wdb-report.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceWdb": "D:\\Projects\\rom-pvt-server-backups\\Runewaker\\Resource\\wdb\\dungeon\\dgn_borsburs.wdb", + "sourceWdb": "F:\\Projects\\rom-pvt-server-backups\\Runewaker\\Resource\\wdb\\dungeon\\dgn_borsburs.wdb", "coordinateSystem": "runewaker-left-handed-y-up", "descriptorCount": 564, "descriptors": [ diff --git a/src/assets/game/dungeons/raksha-temple/import-metadata.json b/src/assets/game/dungeons/raksha-temple/import-metadata.json index 0e274be2..adb504e0 100644 --- a/src/assets/game/dungeons/raksha-temple/import-metadata.json +++ b/src/assets/game/dungeons/raksha-temple/import-metadata.json @@ -149,8 +149,8 @@ "role": "visual", "fileName": "raksha-temple-visual.glb", "relativeFile": "raksha-temple-visual.glb", - "size": 23554528, - "sha256": "3a61098e772a0c656aa25d88aee357f78b84c738e45150fca6fd5059c2b675f7" + "size": 23556584, + "sha256": "1fad5149acfce870be822f5355369f15dd76fba280bbeda31ff7f371c442082f" }, { "role": "collision", diff --git a/src/assets/game/dungeons/raksha-temple/optimization-report.json b/src/assets/game/dungeons/raksha-temple/optimization-report.json index 3e655f5b..172ff19e 100644 --- a/src/assets/game/dungeons/raksha-temple/optimization-report.json +++ b/src/assets/game/dungeons/raksha-temple/optimization-report.json @@ -8,7 +8,7 @@ }, "optimized": { "file": "raksha-temple-visual.optimized.glb", - "size": 23554528 + "size": 23556584 }, "compression": "meshopt+ktx2" } diff --git a/src/assets/game/dungeons/raksha-temple/raksha-temple-visual.glb b/src/assets/game/dungeons/raksha-temple/raksha-temple-visual.glb index 7ca714f6..f6122977 100644 Binary files a/src/assets/game/dungeons/raksha-temple/raksha-temple-visual.glb and b/src/assets/game/dungeons/raksha-temple/raksha-temple-visual.glb differ diff --git a/src/assets/game/dungeons/sardo-castle/import-metadata.json b/src/assets/game/dungeons/sardo-castle/import-metadata.json index 56b7e783..b2f8ea98 100644 --- a/src/assets/game/dungeons/sardo-castle/import-metadata.json +++ b/src/assets/game/dungeons/sardo-castle/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "sardo-castle-visual.glb", - "size": 7756568, - "sha256": "16d759af9f8a3f98281e89362c7fb52dceff6543bbd837a7c6f89015920cdd94", + "size": 7756828, + "sha256": "191396625e301a41afab38e168848a198a9be5918a071dadda841683a7c6261e", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/sardo-castle/optimization-report.json b/src/assets/game/dungeons/sardo-castle/optimization-report.json index 8793ec70..a555146c 100644 --- a/src/assets/game/dungeons/sardo-castle/optimization-report.json +++ b/src/assets/game/dungeons/sardo-castle/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "sardo-castle-visual.optimized.glb", - "size": 7756568, - "checksum": "16d759af9f8a3f98281e89362c7fb52dceff6543bbd837a7c6f89015920cdd94" + "size": 7756828, + "checksum": "191396625e301a41afab38e168848a198a9be5918a071dadda841683a7c6261e" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/sardo-castle/sardo-castle-visual.glb b/src/assets/game/dungeons/sardo-castle/sardo-castle-visual.glb index ee517a1f..7d8a3dca 100644 Binary files a/src/assets/game/dungeons/sardo-castle/sardo-castle-visual.glb and b/src/assets/game/dungeons/sardo-castle/sardo-castle-visual.glb differ diff --git a/src/assets/game/dungeons/the-origin/import-metadata.json b/src/assets/game/dungeons/the-origin/import-metadata.json index 31c254e0..d496a1a9 100644 --- a/src/assets/game/dungeons/the-origin/import-metadata.json +++ b/src/assets/game/dungeons/the-origin/import-metadata.json @@ -149,8 +149,8 @@ "role": "visual", "fileName": "the-origin-visual.glb", "relativeFile": "the-origin-visual.glb", - "size": 28683180, - "sha256": "d1ded0316b2cbaa07458c56864c47c01afaa85e4f33dd13f5d95984d7305fb92" + "size": 28684736, + "sha256": "1f96c2b57a24470a003834a8c91cb0ceda5b01824d5bfa31904afd247c404e88" }, { "role": "collision", diff --git a/src/assets/game/dungeons/the-origin/optimization-report.json b/src/assets/game/dungeons/the-origin/optimization-report.json index 82bb862f..ece6f25a 100644 --- a/src/assets/game/dungeons/the-origin/optimization-report.json +++ b/src/assets/game/dungeons/the-origin/optimization-report.json @@ -8,7 +8,7 @@ }, "optimized": { "file": "the-origin-visual.optimized.glb", - "size": 28683180 + "size": 28684736 }, "compression": "meshopt+ktx2" } diff --git a/src/assets/game/dungeons/the-origin/the-origin-visual.glb b/src/assets/game/dungeons/the-origin/the-origin-visual.glb index 0b340f39..98294b08 100644 Binary files a/src/assets/game/dungeons/the-origin/the-origin-visual.glb and b/src/assets/game/dungeons/the-origin/the-origin-visual.glb differ diff --git a/src/assets/game/dungeons/tomb-seven-heroes/import-metadata.json b/src/assets/game/dungeons/tomb-seven-heroes/import-metadata.json index 5832f0d7..ccaba5d6 100644 --- a/src/assets/game/dungeons/tomb-seven-heroes/import-metadata.json +++ b/src/assets/game/dungeons/tomb-seven-heroes/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "tomb-seven-heroes-visual.glb", - "size": 10910804, - "sha256": "d9f39da6b61c9f841c5d075e4985bc67e533ce422313fdf38d5f6c99dbb304c1", + "size": 10911104, + "sha256": "09250a1fbabf8a03f922c9a3950aee3daa1edc54215cd03ddc9bf5c96e1429bd", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/tomb-seven-heroes/optimization-report.json b/src/assets/game/dungeons/tomb-seven-heroes/optimization-report.json index 66512e7a..6434a8fb 100644 --- a/src/assets/game/dungeons/tomb-seven-heroes/optimization-report.json +++ b/src/assets/game/dungeons/tomb-seven-heroes/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "tomb-seven-heroes-visual.optimized.glb", - "size": 10910804, - "checksum": "d9f39da6b61c9f841c5d075e4985bc67e533ce422313fdf38d5f6c99dbb304c1" + "size": 10911104, + "checksum": "09250a1fbabf8a03f922c9a3950aee3daa1edc54215cd03ddc9bf5c96e1429bd" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/tomb-seven-heroes/tomb-seven-heroes-visual.glb b/src/assets/game/dungeons/tomb-seven-heroes/tomb-seven-heroes-visual.glb index b3f46968..ac22ee40 100644 Binary files a/src/assets/game/dungeons/tomb-seven-heroes/tomb-seven-heroes-visual.glb and b/src/assets/game/dungeons/tomb-seven-heroes/tomb-seven-heroes-visual.glb differ diff --git a/src/assets/game/dungeons/treasure-trove/import-metadata.json b/src/assets/game/dungeons/treasure-trove/import-metadata.json index dd88495f..91fbd9dd 100644 --- a/src/assets/game/dungeons/treasure-trove/import-metadata.json +++ b/src/assets/game/dungeons/treasure-trove/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "treasure-trove-visual.glb", - "size": 7433280, - "sha256": "f4c4dc12df65f8afc3c696308db17cb42b6a5894086dbe870dae2e21e1258448", + "size": 7433780, + "sha256": "c56d429187fd2ffbe20e11d5e7bbfd2128a779c9251fa2ab4b12286caf0de0b6", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/treasure-trove/optimization-report.json b/src/assets/game/dungeons/treasure-trove/optimization-report.json index 29209977..425857b4 100644 --- a/src/assets/game/dungeons/treasure-trove/optimization-report.json +++ b/src/assets/game/dungeons/treasure-trove/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "treasure-trove-visual.optimized.glb", - "size": 7433280, - "checksum": "f4c4dc12df65f8afc3c696308db17cb42b6a5894086dbe870dae2e21e1258448" + "size": 7433780, + "checksum": "c56d429187fd2ffbe20e11d5e7bbfd2128a779c9251fa2ab4b12286caf0de0b6" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/treasure-trove/treasure-trove-visual.glb b/src/assets/game/dungeons/treasure-trove/treasure-trove-visual.glb index b1864df1..dc3b29c5 100644 Binary files a/src/assets/game/dungeons/treasure-trove/treasure-trove-visual.glb and b/src/assets/game/dungeons/treasure-trove/treasure-trove-visual.glb differ diff --git a/src/assets/game/dungeons/warnorken-arena/import-metadata.json b/src/assets/game/dungeons/warnorken-arena/import-metadata.json index d06070a0..7e937acb 100644 --- a/src/assets/game/dungeons/warnorken-arena/import-metadata.json +++ b/src/assets/game/dungeons/warnorken-arena/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "warnorken-arena-visual.glb", - "size": 3752136, - "sha256": "c452a843c2e4123d4fe0d20da41558f5b5a1906548b3d091dc691ffc42ed19d9", + "size": 3752248, + "sha256": "ee81c9e7c94d624977143297ca6c070c40799fdda1c51b92044c70dff53987ea", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/warnorken-arena/optimization-report.json b/src/assets/game/dungeons/warnorken-arena/optimization-report.json index c3efee00..75bc5325 100644 --- a/src/assets/game/dungeons/warnorken-arena/optimization-report.json +++ b/src/assets/game/dungeons/warnorken-arena/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "warnorken-arena-visual.optimized.glb", - "size": 3752136, - "checksum": "c452a843c2e4123d4fe0d20da41558f5b5a1906548b3d091dc691ffc42ed19d9" + "size": 3752248, + "checksum": "ee81c9e7c94d624977143297ca6c070c40799fdda1c51b92044c70dff53987ea" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/warnorken-arena/warnorken-arena-visual.glb b/src/assets/game/dungeons/warnorken-arena/warnorken-arena-visual.glb index 7d2e2c09..29724e7f 100644 Binary files a/src/assets/game/dungeons/warnorken-arena/warnorken-arena-visual.glb and b/src/assets/game/dungeons/warnorken-arena/warnorken-arena-visual.glb differ diff --git a/src/assets/game/dungeons/ystra-labyrinth-boss1/import-metadata.json b/src/assets/game/dungeons/ystra-labyrinth-boss1/import-metadata.json index 45627b00..9ce8b84b 100644 --- a/src/assets/game/dungeons/ystra-labyrinth-boss1/import-metadata.json +++ b/src/assets/game/dungeons/ystra-labyrinth-boss1/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "ystra-labyrinth-boss1-visual.glb", - "size": 2709352, - "sha256": "c8ca72a5fdf487fa71a1aecd1e66ea821183f476540be9a89444b409be957c25", + "size": 2709464, + "sha256": "5e35eeccf5e207477bfb3a38dee75d53a5c5467433a90270b79a9ba8619a6260", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/ystra-labyrinth-boss1/optimization-report.json b/src/assets/game/dungeons/ystra-labyrinth-boss1/optimization-report.json index 83ecfed1..1474c121 100644 --- a/src/assets/game/dungeons/ystra-labyrinth-boss1/optimization-report.json +++ b/src/assets/game/dungeons/ystra-labyrinth-boss1/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "ystra-labyrinth-boss1-visual.optimized.glb", - "size": 2709352, - "checksum": "c8ca72a5fdf487fa71a1aecd1e66ea821183f476540be9a89444b409be957c25" + "size": 2709464, + "checksum": "5e35eeccf5e207477bfb3a38dee75d53a5c5467433a90270b79a9ba8619a6260" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/ystra-labyrinth-boss1/ystra-labyrinth-boss1-visual.glb b/src/assets/game/dungeons/ystra-labyrinth-boss1/ystra-labyrinth-boss1-visual.glb index 8e467526..970db79e 100644 Binary files a/src/assets/game/dungeons/ystra-labyrinth-boss1/ystra-labyrinth-boss1-visual.glb and b/src/assets/game/dungeons/ystra-labyrinth-boss1/ystra-labyrinth-boss1-visual.glb differ diff --git a/src/assets/game/dungeons/ystra-labyrinth-boss2/import-metadata.json b/src/assets/game/dungeons/ystra-labyrinth-boss2/import-metadata.json index 09e5a67a..86f18731 100644 --- a/src/assets/game/dungeons/ystra-labyrinth-boss2/import-metadata.json +++ b/src/assets/game/dungeons/ystra-labyrinth-boss2/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "ystra-labyrinth-boss2-visual.glb", - "size": 2682688, - "sha256": "d52524621220e16d844badc24ee745f4374798031b2b0bc7236decbe3b9588ab", + "size": 2682800, + "sha256": "be717e3d6e6301ff60762552283386d73da04240e6a7d1e36933e83e02d0250a", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/ystra-labyrinth-boss2/optimization-report.json b/src/assets/game/dungeons/ystra-labyrinth-boss2/optimization-report.json index 1a85555b..b84ad75d 100644 --- a/src/assets/game/dungeons/ystra-labyrinth-boss2/optimization-report.json +++ b/src/assets/game/dungeons/ystra-labyrinth-boss2/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "ystra-labyrinth-boss2-visual.optimized.glb", - "size": 2682688, - "checksum": "d52524621220e16d844badc24ee745f4374798031b2b0bc7236decbe3b9588ab" + "size": 2682800, + "checksum": "be717e3d6e6301ff60762552283386d73da04240e6a7d1e36933e83e02d0250a" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/ystra-labyrinth-boss2/ystra-labyrinth-boss2-visual.glb b/src/assets/game/dungeons/ystra-labyrinth-boss2/ystra-labyrinth-boss2-visual.glb index 2cb716e1..b9dc70b1 100644 Binary files a/src/assets/game/dungeons/ystra-labyrinth-boss2/ystra-labyrinth-boss2-visual.glb and b/src/assets/game/dungeons/ystra-labyrinth-boss2/ystra-labyrinth-boss2-visual.glb differ diff --git a/src/assets/game/dungeons/ystra-labyrinth-boss3/import-metadata.json b/src/assets/game/dungeons/ystra-labyrinth-boss3/import-metadata.json index 394d9650..f54dabf1 100644 --- a/src/assets/game/dungeons/ystra-labyrinth-boss3/import-metadata.json +++ b/src/assets/game/dungeons/ystra-labyrinth-boss3/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "ystra-labyrinth-boss3-visual.glb", - "size": 2682688, - "sha256": "d52524621220e16d844badc24ee745f4374798031b2b0bc7236decbe3b9588ab", + "size": 2682800, + "sha256": "be717e3d6e6301ff60762552283386d73da04240e6a7d1e36933e83e02d0250a", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/ystra-labyrinth-boss3/optimization-report.json b/src/assets/game/dungeons/ystra-labyrinth-boss3/optimization-report.json index ee28390e..cd7ce711 100644 --- a/src/assets/game/dungeons/ystra-labyrinth-boss3/optimization-report.json +++ b/src/assets/game/dungeons/ystra-labyrinth-boss3/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "ystra-labyrinth-boss3-visual.optimized.glb", - "size": 2682688, - "checksum": "d52524621220e16d844badc24ee745f4374798031b2b0bc7236decbe3b9588ab" + "size": 2682800, + "checksum": "be717e3d6e6301ff60762552283386d73da04240e6a7d1e36933e83e02d0250a" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/ystra-labyrinth-boss3/ystra-labyrinth-boss3-visual.glb b/src/assets/game/dungeons/ystra-labyrinth-boss3/ystra-labyrinth-boss3-visual.glb index 2cb716e1..b9dc70b1 100644 Binary files a/src/assets/game/dungeons/ystra-labyrinth-boss3/ystra-labyrinth-boss3-visual.glb and b/src/assets/game/dungeons/ystra-labyrinth-boss3/ystra-labyrinth-boss3-visual.glb differ diff --git a/src/assets/game/dungeons/zurhidon-stronghold-124/import-metadata.json b/src/assets/game/dungeons/zurhidon-stronghold-124/import-metadata.json index 3272519a..b546dcda 100644 --- a/src/assets/game/dungeons/zurhidon-stronghold-124/import-metadata.json +++ b/src/assets/game/dungeons/zurhidon-stronghold-124/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "zurhidon-stronghold-124-visual.glb", - "size": 17189492, - "sha256": "a2d450946ca9687395cf65815b931c7817d9f41041afeb36ba1a351ec24104c4", + "size": 17189904, + "sha256": "58be5be365b14e42772a4560c5c1d10fceb8aa8abb2c1771cc21ba2b2e02872a", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/zurhidon-stronghold-124/optimization-report.json b/src/assets/game/dungeons/zurhidon-stronghold-124/optimization-report.json index 994c953a..fefcc5da 100644 --- a/src/assets/game/dungeons/zurhidon-stronghold-124/optimization-report.json +++ b/src/assets/game/dungeons/zurhidon-stronghold-124/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "zurhidon-stronghold-124-visual.optimized.glb", - "size": 17189492, - "checksum": "a2d450946ca9687395cf65815b931c7817d9f41041afeb36ba1a351ec24104c4" + "size": 17189904, + "checksum": "58be5be365b14e42772a4560c5c1d10fceb8aa8abb2c1771cc21ba2b2e02872a" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/zurhidon-stronghold-124/zurhidon-stronghold-124-visual.glb b/src/assets/game/dungeons/zurhidon-stronghold-124/zurhidon-stronghold-124-visual.glb index 42cc87c9..557a009e 100644 Binary files a/src/assets/game/dungeons/zurhidon-stronghold-124/zurhidon-stronghold-124-visual.glb and b/src/assets/game/dungeons/zurhidon-stronghold-124/zurhidon-stronghold-124-visual.glb differ diff --git a/src/assets/game/dungeons/zurhidon-stronghold/import-metadata.json b/src/assets/game/dungeons/zurhidon-stronghold/import-metadata.json index 7d652ad1..af62ff84 100644 --- a/src/assets/game/dungeons/zurhidon-stronghold/import-metadata.json +++ b/src/assets/game/dungeons/zurhidon-stronghold/import-metadata.json @@ -137,8 +137,8 @@ { "role": "visual", "fileName": "zurhidon-stronghold-visual.glb", - "size": 17189492, - "sha256": "a2d450946ca9687395cf65815b931c7817d9f41041afeb36ba1a351ec24104c4", + "size": 17189904, + "sha256": "58be5be365b14e42772a4560c5c1d10fceb8aa8abb2c1771cc21ba2b2e02872a", "compression": "meshopt+ktx2" }, { diff --git a/src/assets/game/dungeons/zurhidon-stronghold/optimization-report.json b/src/assets/game/dungeons/zurhidon-stronghold/optimization-report.json index 51faf9c8..814686e0 100644 --- a/src/assets/game/dungeons/zurhidon-stronghold/optimization-report.json +++ b/src/assets/game/dungeons/zurhidon-stronghold/optimization-report.json @@ -8,8 +8,8 @@ }, "optimized": { "file": "zurhidon-stronghold-visual.optimized.glb", - "size": 17189492, - "checksum": "a2d450946ca9687395cf65815b931c7817d9f41041afeb36ba1a351ec24104c4" + "size": 17189904, + "checksum": "58be5be365b14e42772a4560c5c1d10fceb8aa8abb2c1771cc21ba2b2e02872a" }, "compression": "meshopt+ktx2", "commands": [ diff --git a/src/assets/game/dungeons/zurhidon-stronghold/zurhidon-stronghold-visual.glb b/src/assets/game/dungeons/zurhidon-stronghold/zurhidon-stronghold-visual.glb index 42cc87c9..557a009e 100644 Binary files a/src/assets/game/dungeons/zurhidon-stronghold/zurhidon-stronghold-visual.glb and b/src/assets/game/dungeons/zurhidon-stronghold/zurhidon-stronghold-visual.glb differ diff --git a/src/game/combatStore.test.ts b/src/game/combatStore.test.ts index 4fcccba9..bbe306f4 100644 --- a/src/game/combatStore.test.ts +++ b/src/game/combatStore.test.ts @@ -19,7 +19,7 @@ import { } from "./combatStore"; import { TALENT_NODES, talentNodesForTree } from "./talentCatalog"; import { EMPTY_ITEM_STATS } from "./itemStats"; -import { abilitiesForClass, defaultActionBarForClass } from "./abilityCatalog"; +import { abilitiesForClass, abilityAtLevel, defaultActionBarForClass } from "./abilityCatalog"; import { defaultActionBindings } from "./actionBindings"; import { deriveCharacterStats, MANA_POOL_MULTIPLIER } from "./wotlkStats"; import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation"; @@ -93,14 +93,12 @@ describe("combat store", () => { expect(useCombatStore.getState().mobs.ravager.health).toBe(100); useCombatStore.getState().tick(0.1, 2500); expect(useCombatStore.getState().mobs.ravager.health).toBeLessThan(100); - expect(useCombatStore.getState().resource).toBe( - startingResource - Math.round(startingResource / MANA_POOL_MULTIPLIER * 0.14), - ); + expect(useCombatStore.getState().resource).toBe(startingResource - 17); expect(useCombatStore.getState().activeCast).toBeNull(); expect(useCombatStore.getState().mobs.ravager.engaged).toBe(true); }); - it("uses the displayed primary mana and resolved percentage cost for affordability", () => { + it("uses the displayed primary mana and fixed rank cost for affordability", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 }); const initial = useCombatStore.getState(); const frostbolt = initial.abilities.find((ability) => ability.id === "mage-frostbolt"); @@ -108,7 +106,7 @@ describe("combat store", () => { const [cost] = resolvedAbilityCosts(frostbolt!, initial.talentModifiers, initial); expect(cost).toEqual({ resource: "mana", - amount: Math.round(initial.maxResource / MANA_POOL_MULTIPLIER * 0.14), + amount: 17, }); registerTarget("mana-target", [10, 0, 0]); @@ -129,6 +127,31 @@ describe("combat store", () => { }); }); + it("does not increase a classic spell's mana cost until a new rank is learned", () => { + const pain = abilitiesForClass("priest").find((ability) => ability.id === "priest-shadow-word-pain"); + expect(pain).toBeDefined(); + + const resolvedAtLevel = (level: number) => { + useCombatStore.getState().initializeCharacter({ classId: "priest", level }); + const state = useCombatStore.getState(); + const ranked = abilityAtLevel(pain!, level); + return { + dbcSpellId: ranked.dbcSpellId, + maximumMana: state.maxResource, + cost: resolvedAbilityCosts(ranked, state.talentModifiers, state)[0], + }; + }; + + const rankFourAt26 = resolvedAtLevel(26); + const rankFourAt32 = resolvedAtLevel(32); + const rankFiveAt34 = resolvedAtLevel(34); + + expect(rankFourAt26).toMatchObject({ dbcSpellId: 992, cost: { resource: "mana", amount: 133 } }); + expect(rankFourAt32).toMatchObject({ dbcSpellId: 992, cost: { resource: "mana", amount: 133 } }); + expect(rankFourAt32.maximumMana).toBeGreaterThan(rankFourAt26.maximumMana); + expect(rankFiveAt34).toMatchObject({ dbcSpellId: 2767, cost: { resource: "mana", amount: 188 } }); + }); + it("emits exactly one cast and release event and cancels presentation with the cast", () => { useCombatStore.getState().initializeCharacter({ classId: "mage", level: 1 }); registerTarget("presentation-target", [10, 0, 0]); @@ -1080,13 +1103,16 @@ describe("combat store", () => { expect(useCombatStore.getState().talentModifiers).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: "resource-cost-percent", amount: -15, sourceTalentName: "Convection" }), ])); + const lightningBolt = abilityAtLevel( + abilitiesForClass("shaman").find((ability) => ability.id === "shaman-lightning-bolt")!, + 80, + ); + const expectedLightningBoltCost = Math.round(lightningBolt.cost.amount * 0.85); registerTarget("mana-dummy", [10, 0, 0], 10_000, 0); expect(useCombatStore.getState().castSlot(0, undefined, 10_000).ok).toBe(true); const boltCompletion = useCombatStore.getState().activeCast!.completesAt; useCombatStore.getState().tick(0.1, boltCompletion); - expect(useCombatStore.getState().resource).toBe( - startingMana - Math.round(startingMana / MANA_POOL_MULTIPLIER * 0.1 * 0.85), - ); + expect(useCombatStore.getState().resource).toBe(startingMana - expectedLightningBoltCost); }); it("routes a cast-time direct heal to the selected living party member", () => { diff --git a/src/game/combatStore.ts b/src/game/combatStore.ts index 542f2534..becc1ed7 100644 --- a/src/game/combatStore.ts +++ b/src/game/combatStore.ts @@ -157,7 +157,6 @@ import { equipmentAttackPower, hastedDuration, healthFromTotalStamina, - MANA_POOL_MULTIPLIER, ratingPercent, type CharacterStats, type WotlkAttackKind, @@ -1611,10 +1610,7 @@ export function resolvedAbilityCosts( : cost.resource === activeResourceType ? state.maxResource : state.maxResourcePools[cost.resource] ?? defaultResourceMaximum(cost.resource); - const percentageMaximum = cost.resource === "mana" && ability.source === "wow335-progression" - ? maximum / MANA_POOL_MULTIPLIER - : maximum; - const baseAmount = cost.percentage ? percentageMaximum * cost.amount / 100 : cost.amount; + const baseAmount = cost.percentage ? maximum * cost.amount / 100 : cost.amount; const rawAmount = Math.max(0, Math.round(applyTalentModifierValue( baseAmount, modifiers, diff --git a/src/game/deathRespawn.test.ts b/src/game/deathRespawn.test.ts index 35281744..381dbe58 100644 --- a/src/game/deathRespawn.test.ts +++ b/src/game/deathRespawn.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it } from "vitest"; import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry"; -import { useCombatStore } from "./combatStore"; +import { resolvedAbilityCosts, useCombatStore } from "./combatStore"; +import { abilitiesForClass, abilityAtLevel } from "./abilityCatalog"; import { respawnDefeatedPlayer } from "./deathRespawn"; import { useGameStore } from "./store"; import { useWailingEncounterStore } from "./wailingCavernsEncounter"; -import { MANA_POOL_MULTIPLIER } from "./wotlkStats"; describe("death respawn", () => { beforeEach(() => { @@ -32,10 +32,17 @@ describe("death respawn", () => { useCombatStore.getState().damageMob("naralex-event:ravager:1", 75, 950); useCombatStore.getState().selectMob("respawn-pack:a"); useCombatStore.getState().damageMob("respawn-pack:a", 1, 1000); - expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true); - expect(useCombatStore.getState().resource).toBe( - startingResource - Math.round(startingResource / MANA_POOL_MULTIPLIER * 0.15), + const psychicScream = abilityAtLevel( + abilitiesForClass("priest").find((ability) => ability.id === "priest-psychic-scream")!, + 14, ); + const psychicScreamCost = resolvedAbilityCosts( + psychicScream, + useCombatStore.getState().talentModifiers, + useCombatStore.getState(), + )[0].amount; + expect(useCombatStore.getState().castAbility("priest-psychic-scream", [10, -50, 20], 2000).ok).toBe(true); + expect(useCombatStore.getState().resource).toBe(startingResource - psychicScreamCost); const revisionBefore = useGameStore.getState().resetRevision; useWailingEncounterStore.setState({ diff --git a/src/game/generated/runewakerDungeonDefinitions.generated.ts b/src/game/generated/runewakerDungeonDefinitions.generated.ts index f233ce36..40bb5307 100644 --- a/src/game/generated/runewakerDungeonDefinitions.generated.ts +++ b/src/game/generated/runewakerDungeonDefinitions.generated.ts @@ -102,8 +102,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "kalins-shrine-visual.glb", - "checksum": "769accb48e23c9f80e28697e3e8454ef4148243a69ce247ed06ba4eb35bb0da7", - "size": 8747072, + "checksum": "023c43a56253e97fa0526185c61f76e0e9fa8398b7a35771728566c331cd22a9", + "size": 8851724, "triangleCount": 202382 } ], @@ -133,7 +133,7 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ "navigation": { "id": "navigation", "fileName": "kalins-shrine-navigation.glb", - "checksum": "37fd7f8efac36f83279efbfb33df37646453af214db29597bd14c8e9f19ddc04", + "checksum": "6b77496f7b46890d401f955f9254ae37d25d9a1287a3bc9e787e9a254f9d9071", "size": 327792, "triangleCount": 6811 } @@ -291,8 +291,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "hoto-visual.glb", - "checksum": "0d12b9c6dc477704d2833c81f76113d340da7f07f5885a40bafd1321524e455e", - "size": 6667208, + "checksum": "beff3a180c55166d5ebd7e3610e65b59f8104edfd7596aa22ac3a49c578fcdc0", + "size": 6710184, "triangleCount": 189855 } ], @@ -322,7 +322,7 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ "navigation": { "id": "navigation", "fileName": "hoto-navigation.glb", - "checksum": "2cd78e6d8f52cb6488747a1f87cb688eedb77ccf412fb50674c25f70306477fd", + "checksum": "a2bde2e4b8ed0d0bac8c4669a05192ff0a35ae9c64df0c0a29fe3735b65cbf4b", "size": 482260, "triangleCount": 10029 } @@ -480,8 +480,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "mystic-altar-visual.glb", - "checksum": "765dd1b15bbe79fe1ffc2104e10caece19acaf86c919f0327419cb3609dbf600", - "size": 5552872, + "checksum": "c37f27643598c8fe6401828be2950bc08f299dfe491df37736fda4662c70a96e", + "size": 5552892, "triangleCount": 174521 } ], @@ -663,8 +663,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "kal-turok-hive-visual.glb", - "checksum": "012867deefc4dc5a18c3e1a507309aa48f119cf4c2e88968fdd2787936ed7c24", - "size": 1777616, + "checksum": "75c0c596218cec07003e2def1d74c8e1f5ca3010ed370db558a1786b003f4f1c", + "size": 1777728, "triangleCount": 24026 } ], @@ -838,8 +838,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "paspers-shrine-visual.glb", - "checksum": "82ea6cafd19c0a323c994795699223a07193807da29b300eb12dac41146f1034", - "size": 5625952, + "checksum": "bcf6443e8adb8b7f7dd81f4a97b79aac1c99756a83289bbada31853d2d9adb7c", + "size": 5674908, "triangleCount": 169935 } ], @@ -862,7 +862,7 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ "navigation": { "id": "navigation", "fileName": "paspers-shrine-navigation.glb", - "checksum": "e05fb0b7a3c86bba3ac46606365169dea6c575281b2bb15707addcedb279f108", + "checksum": "4ebba3fb97a99df49c7301dbfcdb8154634cf4e56a85c5ed587c7515221d0a10", "size": 152356, "triangleCount": 3156 } @@ -1020,8 +1020,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "treasure-trove-visual.glb", - "checksum": "f4c4dc12df65f8afc3c696308db17cb42b6a5894086dbe870dae2e21e1258448", - "size": 7433280, + "checksum": "c56d429187fd2ffbe20e11d5e7bbfd2128a779c9251fa2ab4b12286caf0de0b6", + "size": 7433780, "triangleCount": 182387 } ], @@ -1210,8 +1210,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "ystra-labyrinth-boss1-visual.glb", - "checksum": "c8ca72a5fdf487fa71a1aecd1e66ea821183f476540be9a89444b409be957c25", - "size": 2709352, + "checksum": "5e35eeccf5e207477bfb3a38dee75d53a5c5467433a90270b79a9ba8619a6260", + "size": 2709464, "triangleCount": 64284 } ], @@ -1399,8 +1399,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "barren-caves-visual.glb", - "checksum": "d0f733de4b26c0f432c5e160614765f320bf1753a829be269740b340e7415a12", - "size": 1710152, + "checksum": "e6dc49e6aa6fd20cee1b2afa43324a714f8f8284a6becef8dc514509b432f9bb", + "size": 1710188, "triangleCount": 47084 } ], @@ -1574,8 +1574,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "ystra-labyrinth-boss2-visual.glb", - "checksum": "d52524621220e16d844badc24ee745f4374798031b2b0bc7236decbe3b9588ab", - "size": 2682688, + "checksum": "be717e3d6e6301ff60762552283386d73da04240e6a7d1e36933e83e02d0250a", + "size": 2682800, "triangleCount": 21428 } ], @@ -1749,8 +1749,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "ystra-labyrinth-boss3-visual.glb", - "checksum": "d52524621220e16d844badc24ee745f4374798031b2b0bc7236decbe3b9588ab", - "size": 2682688, + "checksum": "be717e3d6e6301ff60762552283386d73da04240e6a7d1e36933e83e02d0250a", + "size": 2682800, "triangleCount": 21428 } ], @@ -1924,8 +1924,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "ice-dwarf-kingdom-visual.glb", - "checksum": "a6b902e85bb6026f18281d77efa235ec880ffc2fc13ea6bfdc431d24af768e00", - "size": 9880192, + "checksum": "41cef8ce1bf6dbf7df0c4e2797a325e4d50a3870d435d46684600894d4aa92e7", + "size": 9880304, "triangleCount": 285517 } ], @@ -2120,8 +2120,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "the-origin-visual.glb", - "checksum": "d1ded0316b2cbaa07458c56864c47c01afaa85e4f33dd13f5d95984d7305fb92", - "size": 28683180, + "checksum": "1f96c2b57a24470a003834a8c91cb0ceda5b01824d5bfa31904afd247c404e88", + "size": 28684736, "triangleCount": 1176881 } ], @@ -2394,8 +2394,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "hall-of-survivors-visual.glb", - "checksum": "18b9d116da599159719ad76eeee45a4d89056ee8c61cc4f21cab9a1d9228fff7", - "size": 15216768, + "checksum": "96b91b24462b8428e8808295dcea29d26de9be8431ea6667dde92cdded4ddf2a", + "size": 15216936, "triangleCount": 380192 } ], @@ -2597,8 +2597,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "cave-water-dragon-visual.glb", - "checksum": "cc57c0285de77690c4a1cdabaecd8b192791cbd05aca38aa21ed96462688ca31", - "size": 8893112, + "checksum": "dd1887f88d6ee9102da0e34ea2b4087742e37a2b8ce779c722bc124effe1faca", + "size": 8893204, "triangleCount": 249082 } ], @@ -2786,8 +2786,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "lair-demon-dragon-visual.glb", - "checksum": "d28481504802203e800985836f2b0907d67f6d6d0495c67500914e749d771ffe", - "size": 4551300, + "checksum": "b371465e96ef978915c9a515846ecf9dccb98607fd549617a1c71f45468fa5a7", + "size": 4551356, "triangleCount": 64688 } ], @@ -2961,8 +2961,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "zurhidon-stronghold-visual.glb", - "checksum": "a2d450946ca9687395cf65815b931c7817d9f41041afeb36ba1a351ec24104c4", - "size": 17189492, + "checksum": "58be5be365b14e42772a4560c5c1d10fceb8aa8abb2c1771cc21ba2b2e02872a", + "size": 17189904, "triangleCount": 364765 } ], @@ -3340,8 +3340,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "demon-stronghold-visual.glb", - "checksum": "9368984b3c183a8db6e8b7e18b7818edcf41b8edd45ffeca1d1df28aadc592a2", - "size": 15166984, + "checksum": "664181dc790b9e68cdc7451dbd0b1170a033a71ca19375743b6e66ca25889723", + "size": 15167544, "triangleCount": 261609 } ], @@ -3530,8 +3530,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "zurhidon-stronghold-124-visual.glb", - "checksum": "a2d450946ca9687395cf65815b931c7817d9f41041afeb36ba1a351ec24104c4", - "size": 17189492, + "checksum": "58be5be365b14e42772a4560c5c1d10fceb8aa8abb2c1771cc21ba2b2e02872a", + "size": 17189904, "triangleCount": 364765 } ], @@ -3733,8 +3733,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "demon-stronghold-125-visual.glb", - "checksum": "ecbacfe274e7eecb104c5807eedf3cc51cd4b7a628967c6331fc93b86396b842", - "size": 15134500, + "checksum": "e2106115dcc216999d2e37d26eeb52844ce4f41b44e19594699f37a54d7f19b9", + "size": 15135060, "triangleCount": 259377 } ], @@ -3922,8 +3922,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "dungeon-of-dalanis-visual.glb", - "checksum": "4c584ef34779e516f8e6ff3a4907eaab1c7c8d49d4c302de66e17b09222d8829", - "size": 11936864, + "checksum": "45cad819bc348dec8926f7de237c625e9d18697b4200804e0440dd16b8976959", + "size": 11937088, "triangleCount": 288949 } ], @@ -4118,8 +4118,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "warnorken-arena-visual.glb", - "checksum": "c452a843c2e4123d4fe0d20da41558f5b5a1906548b3d091dc691ffc42ed19d9", - "size": 3752136, + "checksum": "ee81c9e7c94d624977143297ca6c070c40799fdda1c51b92044c70dff53987ea", + "size": 3752248, "triangleCount": 60751 } ], @@ -4293,8 +4293,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "raksha-temple-visual.glb", - "checksum": "3a61098e772a0c656aa25d88aee357f78b84c738e45150fca6fd5059c2b675f7", - "size": 23554528, + "checksum": "1fad5149acfce870be822f5355369f15dd76fba280bbeda31ff7f371c442082f", + "size": 23556584, "triangleCount": 605820 } ], @@ -4511,8 +4511,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "kawaks-tomb-visual.glb", - "checksum": "de54d223725e2c3dfb0c47a97c208a212ef9423860bd0ac390f1482b302d3a32", - "size": 8963188, + "checksum": "3f1755780540c54a1ba9b528e646ebd39b12ab127e41fa2d497fd7749d202018", + "size": 8963376, "triangleCount": 161613 } ], @@ -4693,8 +4693,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "grafu-castle-visual.glb", - "checksum": "ba4552484ef9cddc1c00607d0dfd4c8fb0bae5b3cd3f992d215a580dac1e96ed", - "size": 11149716, + "checksum": "b775778f457d639e3f21982aaa6bd3ee1b0eba951c711d1ba319b0588e24a38f", + "size": 11150124, "triangleCount": 207925 } ], @@ -4882,8 +4882,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "sardo-castle-visual.glb", - "checksum": "16d759af9f8a3f98281e89362c7fb52dceff6543bbd837a7c6f89015920cdd94", - "size": 7756568, + "checksum": "191396625e301a41afab38e168848a198a9be5918a071dadda841683a7c6261e", + "size": 7756828, "triangleCount": 147586 } ], @@ -5064,8 +5064,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "tomb-seven-heroes-visual.glb", - "checksum": "d9f39da6b61c9f841c5d075e4985bc67e533ce422313fdf38d5f6c99dbb304c1", - "size": 10910804, + "checksum": "09250a1fbabf8a03f922c9a3950aee3daa1edc54215cd03ddc9bf5c96e1429bd", + "size": 10911104, "triangleCount": 155422 } ], @@ -5246,8 +5246,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "aeternal-circle-visual.glb", - "checksum": "42fdcff1b25b58688943a030f69fd14ea839247a7baabbc31b5f6f7faaf49785", - "size": 4963912, + "checksum": "de1ba06b7e3a9237fcff75aff2b00093287975e755cc7e98e77de1b7af6a9c14", + "size": 4963968, "triangleCount": 61639 } ], @@ -5422,8 +5422,8 @@ export const RUNEWAKER_DUNGEON_DEFINITIONS = [ { "id": "visual-000", "fileName": "kulech-bones-nest-visual.glb", - "checksum": "2d912dcf7c0c8e59e36362fd947df1d5554c5dd5060590e1abb112e4c39f606f", - "size": 12146892, + "checksum": "b06b42b3c7f19abf9d645eec6be78f868cf6966e0b472e2ca4a1c36d3cf4d2d1", + "size": 12147008, "triangleCount": 168192 } ], diff --git a/src/game/partyAbilityAi.test.ts b/src/game/partyAbilityAi.test.ts index 8b1250db..71b8b4ed 100644 --- a/src/game/partyAbilityAi.test.ts +++ b/src/game/partyAbilityAi.test.ts @@ -2,12 +2,17 @@ import { beforeEach, describe, expect, it } from "vitest"; import { abilitiesForClass, abilityAtLevel, resourceProfileForClass } from "./abilityCatalog"; import { aiHintsForAbility } from "./abilityRules"; import { useCombatStore } from "./combatStore"; +import { EMPTY_ITEM_STATS } from "./itemStats"; import { commitPartyAbilityUse, choosePartyAbility, partyAbilityIsReady } from "./partyAbilityAi"; import { usePartyStore, type PartyMember } from "./partyStore"; +import { deriveCharacterStats } from "./wotlkStats"; function memberFor(classId: PartyMember["classId"], role: PartyMember["role"]): PartyMember { const base = usePartyStore.getState().members[0]; const profile = resourceProfileForClass(classId); + const maximum = profile.type === "mana" + ? deriveCharacterStats(classId, 80, EMPTY_ITEM_STATS).mana + : profile.maximum; return { ...base, classId, @@ -15,9 +20,9 @@ function memberFor(classId: PartyMember["classId"], role: PartyMember["role"]): level: 80, resourceType: profile.type, resourceName: profile.name, - resource: profile.maximum, - maxResource: profile.maximum, - resourcePools: { [profile.type]: profile.maximum }, + resource: maximum, + maxResource: maximum, + resourcePools: { [profile.type]: maximum }, cooldowns: {}, globalCooldownEndsAt: 0, nextActionAt: 0, diff --git a/src/game/partyStore.test.ts b/src/game/partyStore.test.ts index 0d666dbf..96914222 100644 --- a/src/game/partyStore.test.ts +++ b/src/game/partyStore.test.ts @@ -61,6 +61,18 @@ describe("dungeon party composition", () => { } }); + it("gives mana-using companions real pools that can pay fixed spell-rank costs", () => { + const party = createDungeonParty("fixed-rank-mana", 32, "damage"); + const manaUsers = party.filter((member) => member.resourceType === "mana"); + + expect(manaUsers.length).toBeGreaterThan(0); + for (const member of manaUsers) { + expect(member.maxResource).toBeGreaterThan(100); + expect(member.resource).toBe(member.maxResource); + expect(member.resourcePools.mana).toBe(member.maxResource); + } + }); + it("is deterministic per character and scales every ally to the player level", () => { const first = createDungeonParty("character-42", 18); const second = createDungeonParty("character-42", 18); diff --git a/src/game/partyStore.ts b/src/game/partyStore.ts index dade883d..ccdfe693 100644 --- a/src/game/partyStore.ts +++ b/src/game/partyStore.ts @@ -23,6 +23,7 @@ import { } from "./itemStats"; import { damageAfterEquipmentMitigation, + deriveCharacterStats, } from "./wotlkStats"; import type { InventoryItem } from "./lootTypes"; import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime"; @@ -195,6 +196,18 @@ function memberMaxHealth( return base + healthFromStamina(gearStats.stamina); } +function memberMaxResource( + classId: ClassId, + level: number, + raceId: RaceId, + gearStats: ItemStats = EMPTY_ITEM_STATS, +): number { + const profile = resourceProfileForClass(classId); + return profile.type === "mana" + ? deriveCharacterStats(classId, level, gearStats, raceId).mana + : profile.maximum; +} + function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMember { const maxHealth = memberMaxHealth( member.level, @@ -206,7 +219,19 @@ function withMemberGearStats(member: PartyMember, gearStats: ItemStats): PartyMe const health = member.health <= 0 ? 0 : Math.max(1, Math.round(member.health / Math.max(1, member.maxHealth) * maxHealth)); - return { ...member, gearStats, health, maxHealth }; + const maxResource = memberMaxResource(member.classId, member.level, member.raceId, gearStats); + const resource = Math.max(0, Math.round( + member.resource / Math.max(1, member.maxResource) * maxResource, + )); + return { + ...member, + gearStats, + health, + maxHealth, + resource, + maxResource, + resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }), + }; } function createMember( @@ -242,8 +267,10 @@ function createMember( }); const maxHealth = memberMaxHealth(level, role, gearStats, template.classId, race.id); const resourceProfile = resourceProfileForClass(template.classId); - const maxResource = resourceProfile.maximum; - const resource = resourceProfile.initial; + const maxResource = memberMaxResource(template.classId, level, race.id, gearStats); + const resource = Math.round( + maxResource * (resourceProfile.maximum > 0 ? resourceProfile.initial / resourceProfile.maximum : 0), + ); return { id, name: template.name, @@ -452,11 +479,23 @@ export const usePartyStore = create((set, get) => ({ const health = member.health <= 0 ? 0 : Math.max(1, Math.round(member.health / Math.max(1, member.maxHealth) * maxHealth)); + const maxResource = memberMaxResource( + member.classId, + safeLevel, + member.raceId, + member.gearStats, + ); + const resource = Math.max(0, Math.round( + member.resource / Math.max(1, member.maxResource) * maxResource, + )); return { ...member, level: safeLevel, health, maxHealth, + resource, + maxResource, + resourcePools: Object.freeze({ ...member.resourcePools, [member.resourceType]: resource }), talentRanks: runtimePartyTalentRanksForLevel(member.classId, member.specialization, safeLevel), }; }), diff --git a/src/game/wow335AbilityCatalog.test.ts b/src/game/wow335AbilityCatalog.test.ts index 83db39c6..e1e66225 100644 --- a/src/game/wow335AbilityCatalog.test.ts +++ b/src/game/wow335AbilityCatalog.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog"; +import { abilityAtLevel, type AbilityDefinition, type AbilityEffect } from "./abilityCatalog"; import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog"; function byId(id: string): AbilityDefinition { @@ -60,12 +60,40 @@ describe("WoW 3.3.5 executable effect mapping", () => { expect(byId("wow335-death-knight-death-coil").ranks?.map((rank) => rank.spellId)).not.toContain(47632); }); - it("keeps percentage costs and form resources source-correct", () => { - expect(byId("wow335-mage-frostbolt").cost).toMatchObject({ resource: "mana", amount: 14, percentage: true }); + it("freezes mana costs per learned rank while keeping form resources source-correct", () => { + expect(byId("wow335-mage-frostbolt").cost).toEqual({ resource: "mana", amount: 17 }); + + const pain = byId("wow335-priest-shadow-word-pain"); + expect(abilityAtLevel(pain, 26)).toMatchObject({ + dbcSpellId: 992, + cost: { resource: "mana", amount: 133 }, + }); + expect(abilityAtLevel(pain, 32)).toMatchObject({ + dbcSpellId: 992, + cost: { resource: "mana", amount: 133 }, + }); + expect(abilityAtLevel(pain, 34)).toMatchObject({ + dbcSpellId: 2767, + cost: { resource: "mana", amount: 188 }, + }); + expect(byId("wow335-druid-claw").cost).toMatchObject({ resource: "energy", amount: 45 }); expect(byId("wow335-druid-maul").cost).toMatchObject({ resource: "rage", amount: 15 }); }); + it("never leaves an imported player mana cost tied to maximum mana", () => { + const manaRanks = Object.values(WOW335_ABILITIES_BY_CLASS) + .flat() + .flatMap((ability) => ability.ranks ?? []) + .filter((rank) => rank.cost.resource === "mana"); + + expect(manaRanks.length).toBeGreaterThan(100); + for (const rank of manaRanks) { + expect(rank.cost.percentage, `spell ${rank.spellId} rank ${rank.rank}`).not.toBe(true); + expect(Number.isFinite(rank.cost.amount), `spell ${rank.spellId} rank ${rank.rank}`).toBe(true); + } + }); + it("provides mechanics for previously silent class scripts and hidden proc spells", () => { expect(effectsOfKind(byId("wow335-warrior-slam"), "damage")).not.toEqual([]); expect(effectsOfKind(byId("wow335-death-knight-death-grip"), "pull")).not.toEqual([]); diff --git a/src/game/wow335AbilityCatalog.ts b/src/game/wow335AbilityCatalog.ts index a55243a1..36e062eb 100644 --- a/src/game/wow335AbilityCatalog.ts +++ b/src/game/wow335AbilityCatalog.ts @@ -21,6 +21,7 @@ import type { } from "./combatAuras"; import generatedData from "./wow335AbilityData.generated.json"; import { playerCombatPower } from "./progression"; +import { baseResourcesFor } from "./wotlkStats"; export const BASE_CLASS_STARTER_SOURCE_LEVEL = 10; @@ -1164,8 +1165,19 @@ function targetForRank(rank: Wow335Rank, effects: readonly AbilityEffect[]): Abi return "self"; } -function costForRank(resource: ResourceType, rank: Wow335Rank): number { - if (rank.powerCostPercentage > 0) return rank.powerCostPercentage; +function costForRank(classId: BaseClassId, resource: ResourceType, rank: Wow335Rank): number { + if (rank.powerCostPercentage > 0) { + if (resource === "mana") { + // Freeze DBC percent-of-base-mana costs at the level where this rank is + // learned. Advancing levels or equipping Intellect must never make an + // already-known rank more expensive; learning a later rank still can. + const sourceLevel = Math.max(1, rank.sourceLevel || rank.level); + return Math.max(0, Math.round( + baseResourcesFor(classId, sourceLevel).mana * rank.powerCostPercentage / 100, + )); + } + return rank.powerCostPercentage; + } if (resource === "rage" || resource === "runic-power") return Math.round(rank.powerCost / 10); return rank.powerCost; } @@ -1205,8 +1217,8 @@ function rankDefinition( gcdMs: rank.gcdMs, cost: { resource: rankResource, - amount: costForRank(rankResource, rank), - ...(rank.powerCostPercentage > 0 ? { percentage: true } : {}), + amount: costForRank(classId, rankResource, rank), + ...(rank.powerCostPercentage > 0 && rankResource !== "mana" ? { percentage: true } : {}), }, effects, }; diff --git a/src/platform/dualScreenSync.test.ts b/src/platform/dualScreenSync.test.ts index bf98a32b..d53b12df 100644 --- a/src/platform/dualScreenSync.test.ts +++ b/src/platform/dualScreenSync.test.ts @@ -57,6 +57,15 @@ describe("dual-screen party commands", () => { expect(useCombatStore.getState().cooldowns["priest-psychic-scream"]).toBeGreaterThan(0); }); + it("publishes the active fixed spell-rank cost instead of the catalog's first rank", () => { + useCombatStore.getState().initializeCharacter({ classId: "priest", level: 32 }); + + const pain = currentCompanionSnapshot().game.abilities + .find((ability) => ability.id === "priest-shadow-word-pain"); + + expect(pain).toMatchObject({ resourceCost: 133, resourceLabel: "Mana" }); + }); + it("executes all three party commands from the companion channel", () => { for (const command of ["attack", "stop", "defend"] as const) { executeCompanionCommand({ name: "setPartyCommand", command }); diff --git a/src/platform/dualScreenSync.ts b/src/platform/dualScreenSync.ts index 15315c86..c3b9ce99 100644 --- a/src/platform/dualScreenSync.ts +++ b/src/platform/dualScreenSync.ts @@ -1,7 +1,7 @@ import { classById, type GenderId, type RaceId } from "../app/characterCatalog"; import { useGameStore } from "../game/store"; import type { GameMode, GameOverlay } from "../game/store"; -import { useCombatStore } from "../game/combatStore"; +import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore"; import { activePartyBossObjectives } from "../game/partyRuntime"; import { requireDungeonDefinition } from "../game/dungeonRegistry"; import { @@ -20,7 +20,7 @@ import { type ActionBindingControl, type ActionBindingLayer, } from "../game/actionBindings"; -import type { AbilityTarget } from "../game/abilityCatalog"; +import { abilityAtLevel, resourceProfileForClass, type AbilityTarget } from "../game/abilityCatalog"; import type { DungeonId } from "../game/dungeonCatalog"; import { useManastormStore } from "../game/manastormStore"; import { resolveStagePresentation } from "../game/manastormStagePresentation"; @@ -190,10 +190,24 @@ export function currentCompanionSnapshot(): CompanionSnapshot { const selectedTarget = combat.selectedTargetId ? combat.mobs[combat.selectedTargetId] ?? null : null; const globalCooldownRemaining = Math.max(0, combat.globalCooldownEndsAt - now); const classColor = classById(combat.classId).color; + const activeResourceType = resourceProfileForClass(combat.classId).type; const abilities = ACTION_CONTROLS.flatMap((control): CompanionAbilitySnapshot[] => { const abilityId = combat.actionBindings[actionBindingId(game.actionLayer, control)]; - const ability = abilityId ? combat.abilities.find((candidate) => candidate.id === abilityId) ?? null : null; - if (!ability) return []; + const catalogAbility = abilityId + ? combat.abilities.find((candidate) => candidate.id === abilityId) ?? null + : null; + if (!catalogAbility) return []; + const learnedTalentRank = catalogAbility.talentEntryId === undefined + ? undefined + : combat.talentRanks[`coa-entry-${catalogAbility.talentEntryId}`] ?? 0; + const ability = abilityAtLevel(catalogAbility, combat.level, learnedTalentRank); + const costs = resolvedAbilityCosts(ability, combat.talentModifiers, combat); + const displayCost = costs[0] ?? { resource: ability.cost.resource, amount: 0 }; + const cannotPay = costs.some((cost) => cost.resource === "health" + ? combat.health <= cost.amount + : (cost.resource === activeResourceType + ? combat.resource + : combat.resourcePools[cost.resource] ?? 0) < cost.amount); const personalCooldownRemaining = Math.max(0, (combat.cooldowns[ability.id] ?? 0) - now); const remainingMs = Math.max(personalCooldownRemaining, globalCooldownRemaining); const durationMs = personalCooldownRemaining >= globalCooldownRemaining @@ -207,8 +221,10 @@ export function currentCompanionSnapshot(): CompanionSnapshot { description: ability.description, icon: ability.icon, color: classColor, - resourceCost: ability.cost.amount, - resourceLabel: combat.resourceName, + resourceCost: displayCost.amount, + resourceLabel: displayCost.resource === activeResourceType + ? combat.resourceName + : displayCost.resource.replace("-", " "), cooldownRemaining: remainingMs / 1_000, cooldownDuration: durationMs / 1_000, lockedUntilLevel: combat.level < ability.unlockLevel ? ability.unlockLevel : undefined, @@ -218,7 +234,7 @@ export function currentCompanionSnapshot(): CompanionSnapshot { || combat.health <= 0 || combat.activeCast !== null || remainingMs > 0 - || ability.cost.amount > combat.resource, + || cannotPay, target: ability.target, }]; }); diff --git a/src/scene/ProductionDungeon.test.ts b/src/scene/ProductionDungeon.test.ts index b7ef0043..0c0dfeb0 100644 --- a/src/scene/ProductionDungeon.test.ts +++ b/src/scene/ProductionDungeon.test.ts @@ -1,10 +1,14 @@ import { + AdditiveBlending, BufferAttribute, BufferGeometry, + DoubleSide, Float32BufferAttribute, Group, Mesh, MeshBasicMaterial, + MeshStandardMaterial, + NormalBlending, } from "three"; import { describe, expect, it } from "vitest"; import { @@ -12,6 +16,16 @@ import { collisionTrimeshes, prepareCollision, } from "./productionDungeonCollision"; +import { prepareDungeonMaterial } from "./productionDungeonMaterials"; + +const EMPTY_MATERIAL_ROLES = { + cutoutPatterns: [], + blendPatterns: [], + additivePatterns: [], + cutoutNames: [], + blendNames: [], + additiveNames: [], +}; describe("streamed dungeon collision preparation", () => { it("dequantizes normalized Meshopt position buffers before Rapier consumes them", () => { @@ -94,3 +108,43 @@ describe("streamed dungeon collision preparation", () => { ]); }); }); + +describe("streamed dungeon material preparation", () => { + it("preserves alpha blending declared by the source GLB", () => { + const source = new MeshStandardMaterial({ transparent: true, opacity: 0.2 }); + + const prepared = prepareDungeonMaterial(source, EMPTY_MATERIAL_ROLES); + + expect(prepared).not.toBe(source); + expect(prepared.transparent).toBe(true); + expect(prepared.opacity).toBe(0.2); + expect(prepared.depthWrite).toBe(false); + expect(prepared.blending).toBe(NormalBlending); + expect(prepared.side).toBe(DoubleSide); + }); + + it("preserves alpha masking declared by the source GLB", () => { + const source = new MeshStandardMaterial({ alphaTest: 0.37, transparent: false }); + + const prepared = prepareDungeonMaterial(source, EMPTY_MATERIAL_ROLES); + + expect(prepared.transparent).toBe(false); + expect(prepared.opacity).toBe(1); + expect(prepared.alphaTest).toBe(0.37); + expect(prepared.depthWrite).toBe(true); + }); + + it("keeps explicit dungeon presentation roles authoritative", () => { + const source = new MeshStandardMaterial({ name: "light-volume", alphaTest: 0.5 }); + const roles = { + ...EMPTY_MATERIAL_ROLES, + additiveNames: ["LIGHT-VOLUME"], + }; + + const prepared = prepareDungeonMaterial(source, roles); + + expect(prepared.transparent).toBe(true); + expect(prepared.depthWrite).toBe(false); + expect(prepared.blending).toBe(AdditiveBlending); + }); +}); diff --git a/src/scene/ProductionDungeon.tsx b/src/scene/ProductionDungeon.tsx index 2f7c99b6..be8185e5 100644 --- a/src/scene/ProductionDungeon.tsx +++ b/src/scene/ProductionDungeon.tsx @@ -1,10 +1,7 @@ import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { RigidBody, TrimeshCollider } from "@react-three/rapier"; import { - AdditiveBlending, - DoubleSide, Mesh, - NormalBlending, type InstancedMesh, type Material, type Object3D, @@ -24,49 +21,13 @@ import { import { disposeObject3DResources } from "./threeResourceDisposal"; import { instanceRepeatedStaticMeshes } from "./staticSceneInstancing"; import { useGameGLTF } from "./useGameGLTF"; +import { prepareDungeonMaterial } from "./productionDungeonMaterials"; import { DUNGEON_COLLISION_LOAD_BATCH_SIZE, DUNGEON_VISUAL_LOAD_BATCH_SIZE, advanceDungeonAssetCount, } from "./dungeonLoadSequencing"; -function matchesRole(name: string, patterns: readonly string[], names: readonly string[]): boolean { - const lower = name.toLowerCase(); - return names.some((candidate) => candidate.toLowerCase() === lower) - || patterns.some((pattern) => new RegExp(pattern, "i").test(name)); -} - -function prepareMaterial(source: Material, roles: DungeonMaterialRoles): Material { - const material = source.clone(); - const name = material.name; - material.side = DoubleSide; - - if (matchesRole(name, roles.cutoutPatterns, roles.cutoutNames)) { - material.transparent = false; - material.opacity = 1; - material.alphaTest = 0.5; - material.depthWrite = true; - material.blending = NormalBlending; - } else if (matchesRole(name, roles.additivePatterns, roles.additiveNames)) { - material.transparent = true; - material.depthWrite = false; - material.blending = AdditiveBlending; - } else if (matchesRole(name, roles.blendPatterns, roles.blendNames)) { - material.transparent = true; - material.depthWrite = false; - material.blending = NormalBlending; - } else { - material.transparent = false; - material.opacity = 1; - material.alphaTest = 0; - material.depthWrite = true; - material.blending = NormalBlending; - } - - material.needsUpdate = true; - return material; -} - function prepareVisual(root: Object3D, roles: DungeonMaterialRoles): Object3D { const clone = root.clone(true); const clonedMaterials = new Map(); @@ -77,7 +38,7 @@ function prepareVisual(root: Object3D, roles: DungeonMaterialRoles): Object3D { const prepared = sources.map((source) => { const cached = clonedMaterials.get(source.uuid); if (cached) return cached; - const next = prepareMaterial(source, roles); + const next = prepareDungeonMaterial(source, roles); clonedMaterials.set(source.uuid, next); return next; }); diff --git a/src/scene/productionDungeonMaterials.ts b/src/scene/productionDungeonMaterials.ts new file mode 100644 index 00000000..ccc573c8 --- /dev/null +++ b/src/scene/productionDungeonMaterials.ts @@ -0,0 +1,54 @@ +import { + AdditiveBlending, + DoubleSide, + NormalBlending, + type Material, +} from "three"; +import type { DungeonMaterialRoles } from "../game/dungeonTypes"; + +function matchesRole(name: string, patterns: readonly string[], names: readonly string[]): boolean { + const lower = name.toLowerCase(); + return names.some((candidate) => candidate.toLowerCase() === lower) + || patterns.some((pattern) => new RegExp(pattern, "i").test(name)); +} + +export function prepareDungeonMaterial(source: Material, roles: DungeonMaterialRoles): Material { + const material = source.clone(); + const name = material.name; + material.side = DoubleSide; + + if (matchesRole(name, roles.cutoutPatterns, roles.cutoutNames)) { + material.transparent = false; + material.opacity = 1; + material.alphaTest = 0.5; + material.depthWrite = true; + material.blending = NormalBlending; + } else if (matchesRole(name, roles.additivePatterns, roles.additiveNames)) { + material.transparent = true; + material.depthWrite = false; + material.blending = AdditiveBlending; + } else if (matchesRole(name, roles.blendPatterns, roles.blendNames)) { + material.transparent = true; + material.depthWrite = false; + material.blending = NormalBlending; + } else if (material.alphaTest > 0) { + // Preserve a source GLB's MASK semantics when no presentation override exists. + material.transparent = false; + material.opacity = 1; + material.depthWrite = true; + material.blending = NormalBlending; + } else if (material.transparent) { + // Preserve a source GLB's BLEND semantics when no presentation override exists. + material.depthWrite = false; + material.blending = NormalBlending; + } else { + material.transparent = false; + material.opacity = 1; + material.alphaTest = 0; + material.depthWrite = true; + material.blending = NormalBlending; + } + + material.needsUpdate = true; + return material; +}