import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "public/assets/game/combat-effects/manifest.json"), "utf8")); const particleArchivePath = path.join(projectRoot, "public/assets/game/combat-effects/original/runewaker/manifest.json"); const particleArchive = fs.existsSync(particleArchivePath) ? JSON.parse(fs.readFileSync(particleArchivePath, "utf8")) : null; const catalog = JSON.parse(fs.readFileSync(path.join(projectRoot, "src/game/romPlayerCatalog.generated.json"), "utf8")); const entries = new Map(manifest.entries.filter((entry) => entry.source === "runewaker").map((entry) => [entry.spellId, entry])); const cohorts = [ ["Priest", "rom-priest", "rom-knight"], ["Knight", "rom-knight", "rom-scout"], ["Scout", "rom-scout", "rom-rogue"], ["Rogue", "rom-rogue", "rom-warrior"], ["Mage", "rom-mage", "rom-warlock"], ]; const phases = ["cast-start", "release", "impact", "tick", "aura-start", "aura-end"]; const phaseOrder = (phase) => { const index = phases.indexOf(phase); return index < 0 ? phases.length : index; }; function idsForSkill(skill) { return [...new Set([skill.sourceSkillId, ...(skill.ranks ?? []).map((rank) => rank.sourceSkillId)])] .filter((id) => Number.isInteger(id) && id > 0); } function skillMedia(skill) { const ids = idsForSkill(skill); const mapped = ids.map((id) => entries.get(id)).filter(Boolean); const models = [...new Set(mapped.flatMap((entry) => phases.flatMap((phase) => (entry.phases?.[phase] ?? []).map((asset) => ( `${phase}: ${path.posix.basename(asset.model)}` + (asset.visualCompleteness === "partial" ? " (partial original)" : "") )))))]; const sounds = [...new Set(mapped.flatMap((entry) => (entry.sounds ?? []) .filter((sound) => sound.url) .map((sound) => `${sound.phase ?? "any"}: ${path.posix.basename(sound.sourcePath)}`)))].sort((left, right) => ( phaseOrder(left.split(":", 1)[0]) - phaseOrder(right.split(":", 1)[0]) )); const playableVisuals = mapped.flatMap((entry) => Object.values(entry.phases ?? {}).flat()) .filter((asset) => asset.effectGraphUrl).length; for (const entry of mapped) { for (const sound of entry.sounds ?? []) { if (!sound.url || !fs.existsSync(path.join(projectRoot, "public", sound.url))) { throw new Error(`Missing packaged RuneWaker sound ${entry.key}: ${sound.sourcePath}`); } } } return { skill, ids, models, sounds, playableVisuals }; } function renderTable(rows) { const result = [ "| Skill | Source spell IDs | Effect phase and source model | Sound phase and source cue |", "| --- | --- | --- | --- |", ]; for (const row of rows) { const clean = (value) => value.replaceAll("|", "\\|"); result.push(`| ${clean(row.skill.name)} | ${row.ids.join(", ")} | ${row.models.map(clean).join("
") || "—"} | ${row.sounds.map(clean).join("
") || "—"} |`); } return result.join("\n"); } const sections = []; const summary = []; for (const [label, classId, secondaryClassId] of cohorts) { for (const [kind, secondary] of [["Base", null], ["Accepted elite pair", secondaryClassId]]) { const rows = catalog.skills.filter((skill) => ( skill.classId === classId && (skill.secondaryClassId ?? null) === secondary && !skill.passive )).map(skillMedia); const visual = rows.filter((row) => row.models.length).length; const audio = rows.filter((row) => row.sounds.length).length; const ready = rows.filter((row) => row.playableVisuals > 0).length; summary.push({ label, kind, skills: rows.length, sourceVisuals: visual, packagedSounds: audio, playableVisuals: ready }); sections.push(`## ${label} — ${kind.toLowerCase()}\n\n${renderTable(rows)}`); } } const lines = [ "# RuneWaker primary-five spell media mapping", "", "Generated by `node scripts/combat-effects/audit-rom-media.mjs --write` from the player catalog and combat effect manifest. The five accepted pairings follow `src/game/romFiveIdentities.ts`. Castable skills are listed; passives have no cast media event.", "", `The effect column records original client ROS/RAS references and their cast phase. ${summary.reduce((total, row) => total + row.playableVisuals, 0)} castable skills have browser-playable original phases; other source visuals use the game's generated runtime effect until their controller families are supported. A \`partial original\` phase renders verified source layers and declares its remaining fidelity limit. The sound column lists packaged client cues on their mapped phase. A dash means the source record or matching sound file did not provide that media.`, "", "| Primary class | Skill group | Castable skills | With source effect | With packaged sound | With playable original effect |", "| --- | --- | ---: | ---: | ---: | ---: |", ...summary.map((row) => `| ${row.label} | ${row.kind} | ${row.skills} | ${row.sourceVisuals} | ${row.packagedSounds} | ${row.playableVisuals} |`), "", ...sections.flatMap((section) => [section, ""]), "## Decoded particle archive", "", particleArchive ? `The particle archive covers ${particleArchive.processedModels} referenced ROS models, with ${particleArchive.decodedModels} fully decoded models and ${particleArchive.partialModels} partial models. ${particleArchive.results.filter((result) => result.rendererReady).length} models are renderer-ready and ${particleArchive.results.filter((result) => result.partialRendererReady).length} has a browser-playable partial original. The Priest holy cast's remaining EventTrigger is an audio callback; Urgent Heal's target renders its source sprites, emitted rune mesh, and animated ground decal. Its terrain-triangle decal projection remains approximated by a level plane. The archive exports ${particleArchive.results.reduce((sum, result) => sum + result.spriteControllers, 0)} sprite controllers and ${new Set(particleArchive.results.flatMap((result) => result.graphUrl ? JSON.parse(fs.readFileSync(path.join(projectRoot, "public", result.graphUrl.slice(1)), "utf8")).textures.map((texture) => texture.url) : [])).size} distinct PNG textures. See \`public/assets/game/combat-effects/original/runewaker/manifest.json\` for per-model details.` : "The RuneWaker particle archive has not been packaged.", "", "## Next conversion step", "", "The WebGL sprite renderer plays Priest Urgent Heal, Heal, and Group Heal casting; Urgent Heal's target impact with its source rune mesh and decal; Rising Tide's cast, projectile release, and target impact; Knight Charge cast and Whirlwind Shield impact; and Mage Electric Bolt projectile. Urgent Heal's holy cast EventTrigger starts a looping 3D WAV, and its target impact triggers the source benedictory heal MP3. Rising Tide retains its packaged cast WAV and now plays its ROS-linked projectile release and target impact MP3s. Impact cues use the target position. Urgent Heal's decal uses a level plane in place of the client's terrain projection. The Direct3D source and packaged ROS data remain the reference for subsequent phases.", ]; const markdown = `${lines.join("\n").trimEnd()}\n`; if (process.argv.includes("--write")) { fs.writeFileSync(path.join(projectRoot, "docs/ROM_SPELL_MEDIA_MAPPING.md"), markdown); } console.log(JSON.stringify(summary));