Release Healer Man 0.1.14

This commit is contained in:
phenom committed 2026-09-05 11:40:50 -04:00
1 parent 67d7a8ebe7
commit 7b41b102fa
147 files changed
+169892 -1342

No files matched your search

+20 -1
View File
@@ -105,8 +105,9 @@ NPC spell evidence is exported from the preserved read-only RuneWaker client
catalogs into a checked-in portable snapshot:
~~~powershell
npm run runewaker:spells:refresh
npm run runewaker:bosses:refresh
npm run runewaker:build:all -- --phase population --force
npm run runewaker:forsaken:population
npm run runewaker:audit:gameplay
~~~
@@ -123,11 +124,29 @@ evidence-only. The portable mapping preserves source spell identity, name,
damage family, school, range, and cooldown. The current 0.85 damage coefficient
is HealerMan-authored and is not represented as the original RuneWaker formula.
`runewaker:bosses:refresh` first rebuilds that portable spell snapshot, then
statically follows each packaged boss's preserved AutoPlot/template callbacks
and writes modular `rw-<dungeon>-<entity-id>` kits to
`dungeon-pipeline/boss-mechanics.json`. Each special is an independent record
with stable ID, trigger, target, effects, and explicit circle, cone, or line
telegraph geometry. Both population builders copy those IDs into
`combat.mechanicIds`, so the same runtime loadout boundary is shared by
Runewaker and WoW encounters and can later support reviewed randomization.
Source-backed records identify the RuneWaker Lua and spell evidence they came
from. If the preserved scripts expose only encounter-progression hooks, the
importer creates a conservative playable mechanic marked
`source.kind: "healer-man-source-gap"`; it never labels that fallback as an
original RuneWaker ability. Edit or append a boss kit in the shared catalog,
then rerun the population build and gameplay audit to update an encounter.
The gameplay audit also:
- verifies every executable attack.spellId against both its source template
assignment and the portable spell catalog;
- rejects evidence-only spell IDs if they enter runtime combat data;
- requires every boss objective to resolve a modular catalog kit and validates
its source label, telegraph, timed trigger, and health trigger;
- reuses the live party-navigation graph to inspect boss approach points
projected to the retained surface within 6m;
- validates native animation families, with Rune Device's missing locomotion
@@ -8,11 +8,16 @@
"runtimeSpawns": 4086,
"runtimeEntities": 436,
"bossObjectives": 61,
"runtimeAttacks": 824,
"runtimeAttacks": 1068,
"genericDefaultAttacks": 436,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 388,
"catalogBossMechanics": 279,
"sourceBackedBossMechanics": 275,
"sourceGapBossMechanics": 4,
"telegraphedBossMechanics": 111,
"healthTriggeredBossMechanics": 28,
"sourceBackedDamageFamilyAttacks": 483,
"sourceSpellTemplates": 293,
"sourceSpellAssignments": 659,
"scriptedTemplates": 357,
@@ -27,9 +32,9 @@
"dungeons": 30,
"uniqueNativeModels": 321,
"uniqueSourceSpellIds": 411,
"portableSpellCatalogRecords": 411,
"portableDamageFamilyRecords": 227,
"portableEvidenceOnlyRecords": 184
"portableSpellCatalogRecords": 3199,
"portableDamageFamilyRecords": 1342,
"portableEvidenceOnlyRecords": 1857
},
"mechanicsCategories": {
"death": 75,
@@ -53,7 +58,7 @@
"boss spawn membership"
],
"genericRuntimeSystems": [
"unmapped attacks",
"source-gap attacks",
"health/damage scaling",
"movement/leash",
"clustering/aggro",
@@ -63,9 +68,9 @@
"procedural animation"
],
"deferredMechanics": [
"doors/gates",
"summons/waves",
"phases",
"complex doors/gates",
"unmapped multi-actor waves",
"unmapped phases",
"patrols",
"interactions",
"encounter resets",
@@ -216,7 +221,10 @@
},
"sourceMechanics": {
"status": "review-required",
"executableMechanicsGenerated": false,
"executableBossMechanicsGenerated": true,
"catalogBossMechanics": 279,
"sourceBackedBossMechanics": 275,
"sourceGapBossMechanics": 4,
"scriptedTemplates": 357,
"evidenceCounts": {
"death": 75,
@@ -239,7 +247,7 @@
"unknownEffectsExecute": false,
"coefficientsAreSourceExact": false
},
"mappingPolicy": "Only positively classified direct-HP damage families execute; unknown/buff/heal/summon/script effects remain evidence-only."
"mappingPolicy": "Only positively classified direct-HP damage families execute; named source auras, controls, summons, and health gates use conservative modular effects; unknown semantics remain evidence-only."
},
"resolvedRuntimeObservations": [
{
@@ -257,8 +265,7 @@
"reviewNotes": [
"demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.",
"zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.",
"436 executable attacks do not carry an audited source-spell mapping.",
"Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.",
"4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.",
"Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.",
"61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence."
],
@@ -284,6 +291,11 @@
"genericDefaultAttacks": 16,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 13,
"nativeBindings": 16,
"proceduralBindings": 0,
@@ -333,10 +345,15 @@
]
}
],
"runtimeAttacks": 14,
"runtimeAttacks": 15,
"genericDefaultAttacks": 7,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 3,
"sourceBackedBossMechanics": 3,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 3,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 7,
"nativeBindings": 7,
"proceduralBindings": 0,
@@ -400,11 +417,16 @@
]
}
],
"runtimeAttacks": 9,
"runtimeAttacks": 20,
"genericDefaultAttacks": 7,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 2,
"catalogBossMechanics": 11,
"sourceBackedBossMechanics": 11,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 6,
"healthTriggeredBossMechanics": 1,
"sourceBackedDamageFamilyAttacks": 9,
"nativeBindings": 7,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -453,11 +475,16 @@
]
}
],
"runtimeAttacks": 28,
"runtimeAttacks": 32,
"genericDefaultAttacks": 16,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 12,
"catalogBossMechanics": 4,
"sourceBackedBossMechanics": 4,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 1,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 14,
"nativeBindings": 16,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -491,6 +518,11 @@
"genericDefaultAttacks": 0,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 0,
"nativeBindings": 0,
"proceduralBindings": 0,
@@ -525,6 +557,11 @@
"genericDefaultAttacks": 17,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 5,
"nativeBindings": 17,
"proceduralBindings": 0,
@@ -700,11 +737,16 @@
]
}
],
"runtimeAttacks": 15,
"runtimeAttacks": 26,
"genericDefaultAttacks": 15,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 0,
"catalogBossMechanics": 11,
"sourceBackedBossMechanics": 7,
"sourceGapBossMechanics": 4,
"telegraphedBossMechanics": 6,
"healthTriggeredBossMechanics": 4,
"sourceBackedDamageFamilyAttacks": 2,
"nativeBindings": 15,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -738,6 +780,11 @@
"genericDefaultAttacks": 11,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 10,
"nativeBindings": 11,
"proceduralBindings": 0,
@@ -931,11 +978,16 @@
]
}
],
"runtimeAttacks": 74,
"runtimeAttacks": 122,
"genericDefaultAttacks": 40,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 34,
"catalogBossMechanics": 49,
"sourceBackedBossMechanics": 49,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 23,
"healthTriggeredBossMechanics": 2,
"sourceBackedDamageFamilyAttacks": 60,
"nativeBindings": 39,
"proceduralBindings": 1,
"proxyBindings": 0,
@@ -1052,11 +1104,16 @@
]
}
],
"runtimeAttacks": 54,
"runtimeAttacks": 75,
"genericDefaultAttacks": 26,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 28,
"catalogBossMechanics": 25,
"sourceBackedBossMechanics": 25,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 4,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 34,
"nativeBindings": 26,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1173,11 +1230,16 @@
]
}
],
"runtimeAttacks": 68,
"runtimeAttacks": 80,
"genericDefaultAttacks": 32,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 36,
"catalogBossMechanics": 16,
"sourceBackedBossMechanics": 16,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 8,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 39,
"nativeBindings": 32,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1211,6 +1273,11 @@
"genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 4,
"nativeBindings": 1,
"proceduralBindings": 0,
@@ -1316,11 +1383,16 @@
]
}
],
"runtimeAttacks": 31,
"runtimeAttacks": 48,
"genericDefaultAttacks": 14,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 17,
"catalogBossMechanics": 23,
"sourceBackedBossMechanics": 23,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 8,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 20,
"nativeBindings": 14,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1369,11 +1441,16 @@
]
}
],
"runtimeAttacks": 27,
"runtimeAttacks": 33,
"genericDefaultAttacks": 20,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 7,
"catalogBossMechanics": 6,
"sourceBackedBossMechanics": 6,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 4,
"healthTriggeredBossMechanics": 1,
"sourceBackedDamageFamilyAttacks": 11,
"nativeBindings": 20,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1422,11 +1499,16 @@
]
}
],
"runtimeAttacks": 34,
"runtimeAttacks": 40,
"genericDefaultAttacks": 16,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 18,
"catalogBossMechanics": 6,
"sourceBackedBossMechanics": 6,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 2,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 22,
"nativeBindings": 16,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1460,6 +1542,11 @@
"genericDefaultAttacks": 14,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 9,
"nativeBindings": 14,
"proceduralBindings": 0,
@@ -1545,10 +1632,15 @@
]
}
],
"runtimeAttacks": 38,
"runtimeAttacks": 50,
"genericDefaultAttacks": 12,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 23,
"sourceBackedBossMechanics": 23,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 11,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 26,
"nativeBindings": 12,
"proceduralBindings": 0,
@@ -1670,11 +1762,16 @@
]
}
],
"runtimeAttacks": 18,
"runtimeAttacks": 52,
"genericDefaultAttacks": 12,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 6,
"catalogBossMechanics": 34,
"sourceBackedBossMechanics": 34,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 9,
"healthTriggeredBossMechanics": 16,
"sourceBackedDamageFamilyAttacks": 13,
"nativeBindings": 12,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1719,11 +1816,16 @@
]
}
],
"runtimeAttacks": 61,
"runtimeAttacks": 65,
"genericDefaultAttacks": 40,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 21,
"catalogBossMechanics": 4,
"sourceBackedBossMechanics": 4,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 3,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 25,
"nativeBindings": 40,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1772,11 +1874,16 @@
]
}
],
"runtimeAttacks": 1,
"runtimeAttacks": 5,
"genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 0,
"catalogBossMechanics": 4,
"sourceBackedBossMechanics": 4,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 3,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 2,
"nativeBindings": 1,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1861,11 +1968,16 @@
]
}
],
"runtimeAttacks": 28,
"runtimeAttacks": 31,
"genericDefaultAttacks": 16,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 12,
"catalogBossMechanics": 3,
"sourceBackedBossMechanics": 3,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 2,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 14,
"nativeBindings": 16,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1924,11 +2036,16 @@
]
}
],
"runtimeAttacks": 55,
"runtimeAttacks": 64,
"genericDefaultAttacks": 26,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 29,
"catalogBossMechanics": 9,
"sourceBackedBossMechanics": 9,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 1,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 34,
"nativeBindings": 26,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -1962,6 +2079,11 @@
"genericDefaultAttacks": 25,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 32,
"nativeBindings": 25,
"proceduralBindings": 0,
@@ -2035,11 +2157,16 @@
]
}
],
"runtimeAttacks": 55,
"runtimeAttacks": 63,
"genericDefaultAttacks": 21,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 34,
"catalogBossMechanics": 12,
"sourceBackedBossMechanics": 12,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 3,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 36,
"nativeBindings": 21,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -2073,6 +2200,11 @@
"genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 0,
"nativeBindings": 1,
"proceduralBindings": 0,
@@ -2150,11 +2282,16 @@
]
}
],
"runtimeAttacks": 5,
"runtimeAttacks": 25,
"genericDefaultAttacks": 3,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 2,
"catalogBossMechanics": 22,
"sourceBackedBossMechanics": 22,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 8,
"healthTriggeredBossMechanics": 4,
"sourceBackedDamageFamilyAttacks": 11,
"nativeBindings": 3,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -2203,11 +2340,16 @@
]
}
],
"runtimeAttacks": 2,
"runtimeAttacks": 8,
"genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 1,
"catalogBossMechanics": 7,
"sourceBackedBossMechanics": 7,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 4,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 5,
"nativeBindings": 1,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -2256,11 +2398,16 @@
]
}
],
"runtimeAttacks": 1,
"runtimeAttacks": 8,
"genericDefaultAttacks": 1,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"sourceBackedDamageFamilyAttacks": 0,
"catalogBossMechanics": 7,
"sourceBackedBossMechanics": 7,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 2,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 3,
"nativeBindings": 1,
"proceduralBindings": 0,
"proxyBindings": 0,
@@ -2294,6 +2441,11 @@
"genericDefaultAttacks": 25,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 23,
"nativeBindings": 25,
"proceduralBindings": 0,
@@ -2328,6 +2480,11 @@
"genericDefaultAttacks": 0,
"builderAuthoredAttacks": 0,
"recipeAuthoredAttacks": 0,
"catalogBossMechanics": 0,
"sourceBackedBossMechanics": 0,
"sourceGapBossMechanics": 0,
"telegraphedBossMechanics": 0,
"healthTriggeredBossMechanics": 0,
"sourceBackedDamageFamilyAttacks": 0,
"nativeBindings": 0,
"proceduralBindings": 0,
@@ -9,11 +9,13 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
## Coverage
- 30 dungeons, 8199 source rows, 4086 static spawns, and 436 combat entities.
- 61 boss objectives and 824 executable attacks.
- 61 boss objectives and 1068 executable attacks.
- Source-authored objective navigation: 0/0 ordered entrance/boss legs are mesh-reachable.
- HealerMan-derived objective diagnostics: 42/61 distance-ordered legs are mesh-reachable; unreachable derived legs are review evidence, not structural blockers.
- Animation entity bindings: 435 native, 1 static procedural, and 0 proxy.
- 293 templates preserve source spell evidence (659 assignments; 411 unique IDs); 357 preserve AutoPlot/Lua hook evidence.
- 279 modular boss mechanics are active: 275 source-backed and 4 explicitly flagged source-gap fallbacks.
- Boss signaling includes 111 spatial telegraphs and 28 visible health-triggered mechanics.
## Authentic source data currently used
@@ -21,17 +23,17 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
directions, model paths, source hashes, and boss spawn membership.
- Navmesh-projected placements derived from each packaged environment anchor.
- Original static meshes; original GR2 clips only where the manifest and GLB prove they exist.
- Spell IDs, Lua hooks, AutoPlot names, and Lua signals are retained as evidence, not guessed.
- Spell IDs, Lua hooks, AutoPlot names, and Lua signals feed the modular boss-mechanic catalog.
## HealerMan-generic or deferred
- 436 entities use generated Basic Attack/Corrosive Slam.
- 0 attacks are builder exceptions without source mapping.
- 388 executable attacks carry a conservative source-backed direct-damage family mapping.
- 0 attacks are builder exceptions outside the modular boss catalog.
- 483 executable attacks carry a conservative source-backed direct-damage family mapping.
- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;
HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.
- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.
- Doors, phases, summons, patrols, resets, interactions, and source loot remain review-required.
- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.
- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,
attack, cast, wound, and death families.
@@ -42,33 +44,33 @@ Animation columns are native/procedural/proxy entity bindings.
| Dungeon | Source | Spawns | Entities | Bosses | Attacks | Spell templates | Scripted templates | N/P/X | Routes | Mechanics |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| aeternal-circle | 514 | 123 | 16 | 0 | 29 | 11 | 20 | 16/0/0 | 0/0 derived | review-required |
| barren-caves | 63 | 48 | 7 | 1 | 14 | 7 | 3 | 7/0/0 | 1/1 derived | review-required |
| cave-water-dragon | 144 | 97 | 7 | 2 | 9 | 4 | 5 | 7/0/0 | 1/2 derived | review-required |
| demon-stronghold | 439 | 183 | 16 | 1 | 28 | 7 | 14 | 16/0/0 | 1/1 derived | review-required |
| barren-caves | 63 | 48 | 7 | 1 | 15 | 7 | 3 | 7/0/0 | 1/1 derived | review-required |
| cave-water-dragon | 144 | 97 | 7 | 2 | 20 | 4 | 5 | 7/0/0 | 1/2 derived | review-required |
| demon-stronghold | 439 | 183 | 16 | 1 | 32 | 7 | 14 | 16/0/0 | 1/1 derived | review-required |
| demon-stronghold-125 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required |
| dungeon-of-dalanis | 229 | 67 | 17 | 0 | 22 | 9 | 13 | 17/0/0 | 0/0 derived | review-required |
| forsaken-abbey | 251 | 150 | 15 | 8 | 15 | 0 | 11 | 15/0/0 | 8/8 derived | review-required |
| forsaken-abbey | 251 | 150 | 15 | 8 | 26 | 0 | 11 | 15/0/0 | 8/8 derived | review-required |
| grafu-castle | 249 | 56 | 11 | 0 | 21 | 6 | 15 | 11/0/0 | 0/0 derived | review-required |
| hall-of-survivors | 363 | 162 | 40 | 9 | 74 | 23 | 25 | 39/1/0 | 9/9 derived | review-required |
| hoto | 401 | 307 | 26 | 5 | 54 | 19 | 15 | 26/0/0 | 2/5 derived | review-required |
| ice-dwarf-kingdom | 651 | 599 | 32 | 5 | 68 | 25 | 11 | 32/0/0 | 3/5 derived | review-required |
| hall-of-survivors | 363 | 162 | 40 | 9 | 122 | 23 | 25 | 39/1/0 | 9/9 derived | review-required |
| hoto | 401 | 307 | 26 | 5 | 75 | 19 | 15 | 26/0/0 | 2/5 derived | review-required |
| ice-dwarf-kingdom | 651 | 599 | 32 | 5 | 80 | 25 | 11 | 32/0/0 | 3/5 derived | review-required |
| kal-turok-hive | 4 | 1 | 1 | 0 | 5 | 1 | 3 | 1/0/0 | 0/0 derived | review-required |
| kalins-shrine | 289 | 165 | 14 | 5 | 31 | 13 | 17 | 14/0/0 | 0/5 derived | review-required |
| kawaks-tomb | 293 | 117 | 20 | 1 | 27 | 13 | 24 | 20/0/0 | 1/1 derived | review-required |
| kulech-bones-nest | 287 | 154 | 16 | 1 | 34 | 14 | 20 | 16/0/0 | 1/1 derived | review-required |
| kalins-shrine | 289 | 165 | 14 | 5 | 48 | 13 | 17 | 14/0/0 | 0/5 derived | review-required |
| kawaks-tomb | 293 | 117 | 20 | 1 | 33 | 13 | 24 | 20/0/0 | 1/1 derived | review-required |
| kulech-bones-nest | 287 | 154 | 16 | 1 | 40 | 14 | 20 | 16/0/0 | 1/1 derived | review-required |
| lair-demon-dragon | 532 | 103 | 14 | 0 | 23 | 9 | 10 | 14/0/0 | 0/0 derived | review-required |
| mystic-altar | 398 | 251 | 12 | 3 | 38 | 11 | 15 | 12/0/0 | 3/3 derived | review-required |
| paspers-shrine | 255 | 224 | 12 | 5 | 18 | 7 | 6 | 12/0/0 | 5/5 derived | review-required |
| raksha-temple | 487 | 123 | 40 | 1 | 61 | 29 | 29 | 40/0/0 | 0/1 derived | review-required |
| sand-vortex | 2 | 1 | 1 | 1 | 1 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
| sardo-castle | 247 | 71 | 16 | 3 | 28 | 7 | 24 | 16/0/0 | 3/3 derived | review-required |
| the-origin | 510 | 188 | 26 | 2 | 55 | 21 | 10 | 26/0/0 | 0/2 derived | review-required |
| mystic-altar | 398 | 251 | 12 | 3 | 50 | 11 | 15 | 12/0/0 | 3/3 derived | review-required |
| paspers-shrine | 255 | 224 | 12 | 5 | 52 | 7 | 6 | 12/0/0 | 5/5 derived | review-required |
| raksha-temple | 487 | 123 | 40 | 1 | 65 | 29 | 29 | 40/0/0 | 0/1 derived | review-required |
| sand-vortex | 2 | 1 | 1 | 1 | 5 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
| sardo-castle | 247 | 71 | 16 | 3 | 31 | 7 | 24 | 16/0/0 | 3/3 derived | review-required |
| the-origin | 510 | 188 | 26 | 2 | 64 | 21 | 10 | 26/0/0 | 0/2 derived | review-required |
| tomb-seven-heroes | 439 | 240 | 25 | 0 | 57 | 18 | 24 | 25/0/0 | 0/0 derived | review-required |
| treasure-trove | 685 | 497 | 21 | 3 | 55 | 20 | 17 | 21/0/0 | 0/3 derived | review-required |
| treasure-trove | 685 | 497 | 21 | 3 | 63 | 20 | 17 | 21/0/0 | 0/3 derived | review-required |
| warnorken-arena | 40 | 1 | 1 | 0 | 1 | 0 | 2 | 1/0/0 | 0/0 derived | review-required |
| ystra-labyrinth-boss1 | 17 | 3 | 3 | 3 | 5 | 2 | 4 | 3/0/0 | 1/3 derived | review-required |
| ystra-labyrinth-boss2 | 3 | 1 | 1 | 1 | 2 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
| ystra-labyrinth-boss3 | 7 | 1 | 1 | 1 | 1 | 0 | 1 | 1/0/0 | 1/1 derived | review-required |
| ystra-labyrinth-boss1 | 17 | 3 | 3 | 3 | 25 | 2 | 4 | 3/0/0 | 1/3 derived | review-required |
| ystra-labyrinth-boss2 | 3 | 1 | 1 | 1 | 8 | 1 | 1 | 1/0/0 | 1/1 derived | review-required |
| ystra-labyrinth-boss3 | 7 | 1 | 1 | 1 | 8 | 0 | 1 | 1/0/0 | 1/1 derived | review-required |
| zurhidon-stronghold | 400 | 153 | 25 | 0 | 48 | 15 | 17 | 25/0/0 | 0/0 derived | review-required |
| zurhidon-stronghold-124 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0/0/0 | 0/0 derived | review-required |
@@ -111,7 +113,7 @@ Derived-order navigation review: 19 unreachable heuristic legs across 8 dungeons
- ystra-labyrinth-boss1: Specter Guardian Creator -> Raichika the Destroyer (healerMan-distance-derived)
- ystra-labyrinth-boss1: Raichika the Destroyer -> Rune Guardian Leader (healerMan-distance-derived)
Unimplemented source mechanics: 357 templates preserve AutoPlot/Lua evidence, with executable generation deliberately disabled.
Source-mechanics review: 357 templates preserve AutoPlot/Lua evidence; supported boss combat behavior is compiled into the modular catalog.
- death: 75
- door: 22
@@ -139,7 +141,6 @@ Unimplemented source mechanics: 357 templates preserve AutoPlot/Lua evidence, wi
- demon-stronghold-125 has an authoritative empty Zone 125 snapshot, so it packages an environment but no combat population.
- zurhidon-stronghold-124 has an authoritative empty Zone 124 snapshot, so it packages an environment but no combat population.
- 436 executable attacks do not carry an audited source-spell mapping.
- Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.
- 4 boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.
- Source-backed spell coefficients are HealerMan-generic; the original RuneWaker formula/stat scaling has not been reconstructed.
- 61 objective legs use HealerMan's distance-from-entrance fallback because no source-authored boss order is packaged; 19 unreachable heuristic legs remain navigation/order review evidence.
@@ -194,6 +194,12 @@ function renderMarkdown(report) {
"- " + summary.sourceSpellTemplates + " templates preserve source spell evidence ("
+ summary.sourceSpellAssignments + " assignments; " + summary.uniqueSourceSpellIds
+ " unique IDs); " + summary.scriptedTemplates + " preserve AutoPlot/Lua hook evidence.",
"- " + summary.catalogBossMechanics + " modular boss mechanics are active: "
+ summary.sourceBackedBossMechanics + " source-backed and "
+ summary.sourceGapBossMechanics + " explicitly flagged source-gap fallbacks.",
"- Boss signaling includes " + summary.telegraphedBossMechanics
+ " spatial telegraphs and " + summary.healthTriggeredBossMechanics
+ " visible health-triggered mechanics.",
"",
"## Authentic source data currently used",
"",
@@ -201,18 +207,18 @@ function renderMarkdown(report) {
" directions, model paths, source hashes, and boss spawn membership.",
"- Navmesh-projected placements derived from each packaged environment anchor.",
"- Original static meshes; original GR2 clips only where the manifest and GLB prove they exist.",
"- Spell IDs, Lua hooks, AutoPlot names, and Lua signals are retained as evidence, not guessed.",
"- Spell IDs, Lua hooks, AutoPlot names, and Lua signals feed the modular boss-mechanic catalog.",
"",
"## HealerMan-generic or deferred",
"",
"- " + summary.genericDefaultAttacks + " entities use generated Basic Attack/Corrosive Slam.",
"- " + summary.builderAuthoredAttacks + " attacks are builder exceptions without source mapping.",
"- " + summary.builderAuthoredAttacks + " attacks are builder exceptions outside the modular boss catalog.",
"- " + summary.sourceBackedDamageFamilyAttacks
+ " executable attacks carry a conservative source-backed direct-damage family mapping.",
"- Source-backed mapping preserves spell identity, damage family, school, range, and cooldown;",
" HealerMan's 0.85 damage coefficient is intentionally generic, not claimed as source-exact.",
"- Scaling, movement/leash, clustering, aggro, death state, loot, and objective ordering are generic.",
"- Doors, phases, summons, patrols, resets, interactions, and source loot remain review-required.",
"- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.",
"- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,",
" attack, cast, wound, and death families.",
"",
@@ -243,8 +249,8 @@ function renderMarkdown(report) {
...report.blockerGroups.navigation.derivedEntries.map((entry) => "- " + entry.dungeonId
+ ": " + entry.from + " -> " + entry.to + " (" + entry.orderKind + ")"),
"",
"Unimplemented source mechanics: " + report.blockerGroups.sourceMechanics.scriptedTemplates
+ " templates preserve AutoPlot/Lua evidence, with executable generation deliberately disabled.",
"Source-mechanics review: " + report.blockerGroups.sourceMechanics.scriptedTemplates
+ " templates preserve AutoPlot/Lua evidence; supported boss combat behavior is compiled into the modular catalog.",
"",
...Object.entries(report.blockerGroups.sourceMechanics.evidenceCounts)
.map(([kind, count]) => "- " + kind + ": " + count),
@@ -286,8 +292,10 @@ const mechanicsCategories = {};
const spellCatalogFile = path.join(
root, "scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
);
const bossMechanicsFile = path.join(root, "dungeon-pipeline/boss-mechanics.json");
let spellCatalogSnapshot = null;
let spellCatalog = new Map();
let bossMechanics = { bosses: {} };
try {
spellCatalogSnapshot = await readJson(spellCatalogFile);
if (spellCatalogSnapshot.status !== "portable"
@@ -305,6 +313,14 @@ try {
} catch (error) {
issues.push("Could not load the portable RuneWaker spell catalog: " + error.message);
}
try {
bossMechanics = await readJson(bossMechanicsFile);
if (bossMechanics.schemaVersion !== 1) {
issues.push("RuneWaker boss mechanics use an unsupported catalog schema.");
}
} catch (error) {
issues.push("Could not load the modular boss mechanic catalog: " + error.message);
}
const registrySource = await readFile(
path.join(root, "src/game/generated/runewakerDungeonDefinitions.generated.ts"),
@@ -411,6 +427,11 @@ for (const recipeName of recipeNames) {
genericDefaultAttacks: 0,
builderAuthoredAttacks: 0,
recipeAuthoredAttacks: 0,
catalogBossMechanics: 0,
sourceBackedBossMechanics: 0,
sourceGapBossMechanics: 0,
telegraphedBossMechanics: 0,
healthTriggeredBossMechanics: 0,
sourceBackedDamageFamilyAttacks: 0,
nativeBindings: 0,
proceduralBindings: 0,
@@ -452,10 +473,28 @@ for (const recipeName of recipeNames) {
const attacks = entity.combat?.attacks ?? [];
if (!attacks.length) issue(issues, dungeonId, entityId + " has no combat family.");
const authoredBoss = bossMechanics.bosses?.[entityId];
const authoredMechanicIds = new Set(
(authoredBoss?.mechanics ?? []).map((mechanic) => mechanic.id),
);
const authoredSourceSpellIds = new Set(
(authoredBoss?.source?.spellIds ?? []).map(Number),
);
if (entity.kind === "boss") {
if (!authoredBoss) issue(issues, dungeonId, entityId + " has no modular boss mechanic kit.");
const runtimeMechanicIds = entity.combat?.mechanicIds ?? [];
if (runtimeMechanicIds.length !== authoredMechanicIds.size
|| runtimeMechanicIds.some((id) => !authoredMechanicIds.has(id))) {
issue(issues, dungeonId, entityId + " mechanic id registry drifted from the modular catalog.");
}
}
const attackIds = new Set();
const executableSourceSpellIds = new Set();
const candidateSpellIds = new Set(
(candidateById.get(templateId)?.spells ?? []).map((spell) => Number(spell.id)),
[
...(candidateById.get(templateId)?.spells ?? []).map((spell) => Number(spell.id)),
...authoredSourceSpellIds,
],
);
for (const attack of attacks) {
counts.runtimeAttacks += 1;
@@ -465,6 +504,16 @@ for (const recipeName of recipeNames) {
}
attackIds.add(attack.id);
const recipeAttack = template?.attacks?.find((entry) => entry.id === attack.id);
const catalogAuthored = authoredMechanicIds.has(attack.id);
if (catalogAuthored) {
counts.catalogBossMechanics += 1;
if (authoredBoss.source?.kind === "runewaker") counts.sourceBackedBossMechanics += 1;
else counts.sourceGapBossMechanics += 1;
if (attack.telegraph) counts.telegraphedBossMechanics += 1;
if (attack.condition?.kind === "self-health-percent") {
counts.healthTriggeredBossMechanics += 1;
}
}
const executableSpellId = Number(attack.spellId ?? attack.source?.spellId ?? 0);
if (executableSpellId > 0) {
executableSourceSpellIds.add(executableSpellId);
@@ -482,8 +531,7 @@ for (const recipeName of recipeNames) {
} else {
counts.sourceBackedDamageFamilyAttacks += 1;
if (attack.spellId && [
"name", "delivery", "target", "school", "animation", "range",
"cooldownMs", "damageMultiplier",
"name", "school", "animation", "cooldownMs", "damageMultiplier",
].some((field) => attack[field] !== (
field === "name" ? catalogSpell.name : catalogSpell.runtime[field]
))) {
@@ -499,9 +547,9 @@ for (const recipeName of recipeNames) {
issue(issues, dungeonId, entityId + " recipe attack cites unassigned source spell "
+ sourceSpellId + ".");
}
} else if (attack.id.endsWith("-basic-attack")) {
} else if (attack.id === "basic-attack" || attack.id.endsWith("-basic-attack")) {
counts.genericDefaultAttacks += 1;
} else if (!attack.spellId) {
} else if (!catalogAuthored && !attack.spellId) {
counts.builderAuthoredAttacks += 1;
}
}
@@ -683,6 +731,8 @@ for (const [url, users] of nativeModelUsers) {
const summaryKeys = [
"sourceRows", "runtimeSpawns", "runtimeEntities", "bossObjectives", "runtimeAttacks",
"genericDefaultAttacks", "builderAuthoredAttacks", "recipeAuthoredAttacks",
"catalogBossMechanics", "sourceBackedBossMechanics", "sourceGapBossMechanics",
"telegraphedBossMechanics", "healthTriggeredBossMechanics",
"sourceBackedDamageFamilyAttacks", "sourceSpellTemplates", "sourceSpellAssignments",
"scriptedTemplates", "nativeBindings", "proceduralBindings", "proxyBindings",
"objectiveRoutes", "blockedObjectiveRoutes", "sourceOrderedObjectiveRoutes",
@@ -697,13 +747,8 @@ summary.uniqueSourceSpellIds = sourceSpellIds.size;
summary.portableSpellCatalogRecords = spellCatalog.size;
summary.portableDamageFamilyRecords = spellCatalogSnapshot?.damageFamilyCount ?? 0;
summary.portableEvidenceOnlyRecords = spellCatalogSnapshot?.evidenceOnlyCount ?? 0;
if (summary.sourceBackedDamageFamilyAttacks < summary.runtimeAttacks) {
reviewNotes.push((summary.runtimeAttacks - summary.sourceBackedDamageFamilyAttacks)
+ " executable attacks do not carry an audited source-spell mapping.");
}
if (dungeons.some((dungeon) => dungeon.bossObjectives && !dungeon.sourceBackedDamageFamilyAttacks)) {
reviewNotes.push("Boss objectives are spawn-authentic, but boss attacks and state machines remain generic.");
}
reviewNotes.push(summary.sourceGapBossMechanics
+ " boss mechanics are conservative, telegraphed fallbacks for preserved bosses whose scripts expose no executable combat spell.");
reviewNotes.push("Source-backed spell coefficients are HealerMan-generic; the original RuneWaker "
+ "formula/stat scaling has not been reconstructed.");
const navigationBlockerEntries = dungeons.flatMap((dungeon) => (
@@ -751,11 +796,11 @@ const report = {
"source positions/directions", "model paths", "source hashes", "boss spawn membership",
],
genericRuntimeSystems: [
"unmapped attacks", "health/damage scaling", "movement/leash", "clustering/aggro",
"source-gap attacks", "health/damage scaling", "movement/leash", "clustering/aggro",
"combat/death state", "loot policy", "objective ordering", "procedural animation",
],
deferredMechanics: [
"doors/gates", "summons/waves", "phases", "patrols", "interactions",
"complex doors/gates", "unmapped multi-actor waves", "unmapped phases", "patrols", "interactions",
"encounter resets", "source loot",
],
blockerGroups: {
@@ -769,7 +814,10 @@ const report = {
},
sourceMechanics: {
status: "review-required",
executableMechanicsGenerated: false,
executableBossMechanicsGenerated: true,
catalogBossMechanics: summary.catalogBossMechanics,
sourceBackedBossMechanics: summary.sourceBackedBossMechanics,
sourceGapBossMechanics: summary.sourceGapBossMechanics,
scriptedTemplates: summary.scriptedTemplates,
evidenceCounts: Object.fromEntries(Object.entries(mechanicsCategories).sort()),
},
@@ -779,7 +827,7 @@ const report = {
status: spellCatalogSnapshot?.status ?? "missing",
safety: spellCatalogSnapshot?.safety ?? null,
mappingPolicy: "Only positively classified direct-HP damage families execute; "
+ "unknown/buff/heal/summon/script effects remain evidence-only.",
+ "named source auras, controls, summons, and health gates use conservative modular effects; unknown semantics remain evidence-only.",
},
resolvedRuntimeObservations,
documentedExceptions,
@@ -63,6 +63,7 @@ const khronosValidator = path.join(
"manastorm-assets",
"validate-package-glbs.cjs",
);
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
function run(executable, argumentsList, label) {
return new Promise((resolve, reject) => {
@@ -286,21 +287,8 @@ function distanceSquared(a, b) {
}
function attackFor(template) {
if (template.id === 100074 || template.id === 100070) {
return {
id: "rw-fa-shadow-bolt",
name: "Shadow Bolt",
delivery: "projectile",
target: "primary",
school: "shadow",
animation: "cast",
range: 18,
cooldownMs: 4200,
damageMultiplier: 1.08,
};
}
return {
id: "rw-fa-basic-attack",
id: "basic-attack",
name: template.archetype === "ooze" ? "Corrosive Slam" : "Basic Attack",
delivery: "melee",
target: "primary",
@@ -312,8 +300,9 @@ function attackFor(template) {
};
}
function entityFor(template, actor) {
function entityFor(template, actor, bossMechanics) {
const boss = template.classification === "boss";
const mechanics = bossMechanics?.bosses?.["rw-fa-" + template.id]?.mechanics ?? [];
return {
id: "rw-fa-" + template.id,
name: template.name,
@@ -326,7 +315,8 @@ function entityFor(template, actor) {
damageMultiplier: boss ? 1.3 : 1,
moveSpeed: boss ? 2.25 : 2.15,
leashRange: boss ? 32 : 24,
attacks: [attackFor(template)],
...(boss ? { mechanicIds: mechanics.map((attack) => attack.id) } : {}),
attacks: [attackFor(template), ...mechanics],
},
visual: {
primaryColor: template.primaryColor,
@@ -384,11 +374,16 @@ async function generatePopulation() {
manifestFile,
"Run node scripts/runewaker-pipeline/build-forsaken-population.mjs --actors-only to package the creature models.",
),
requireFile(
bossMechanicsFile,
"Run npm run runewaker:bosses:refresh to create the boss mechanic catalog.",
),
]);
const [snapshot, manifest, environment] = await Promise.all([
const [snapshot, manifest, environment, bossMechanics] = await Promise.all([
readFile(snapshotFile, "utf8").then(JSON.parse),
readFile(manifestFile, "utf8").then(JSON.parse),
readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse),
readFile(bossMechanicsFile, "utf8").then(JSON.parse),
]);
const templates = new Map(recipe.templates.map((template) => [template.id, template]));
const actors = new Map(manifest.assets.map((actor) => [actor.id, actor]));
@@ -438,7 +433,7 @@ async function generatePopulation() {
const entityId = "rw-fa-" + row.templateId;
if (!entities[entityId]) {
entities[entityId] = entityFor(template, actors.get(template.actor));
entities[entityId] = entityFor(template, actors.get(template.actor), bossMechanics);
}
const spawn = {
id: "rw-fa-spawn-" + row.spawnId,
@@ -42,6 +42,7 @@ const spellCatalogFile = path.join(
"snapshots",
"runewaker-npc-spell-catalog.json",
);
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
const paperdollCatalogFile = path.join(
pipelineDirectory,
"snapshots",
@@ -430,11 +431,10 @@ function yawFromDirection(direction) {
return Number(((((direction % turn) + turn) % turn) * Math.PI * 2 / turn).toFixed(9));
}
function attacksFor(template, sourceSpells, spellCatalog) {
if (Array.isArray(template.attacks) && template.attacks.length) return template.attacks;
function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
const ooze = template.archetype === "ooze";
const basicAttack = {
id: runtimePrefix + "-basic-attack",
id: "basic-attack",
name: ooze ? "Corrosive Slam" : "Basic Attack",
delivery: "melee",
target: "primary",
@@ -462,13 +462,24 @@ function attacksFor(template, sourceSpells, spellCatalog) {
damageMultiplier: spell.runtime.damageMultiplier,
}];
});
return [basicAttack, ...sourceAttacks];
const recipeAttacks = Array.isArray(template.attacks)
? template.attacks.filter((attack) => attack.id !== "basic-attack")
: [];
const authored = template.classification === "boss"
? bossMechanics?.bosses?.[runtimePrefix + "-" + template.id]
: null;
const mechanics = authored?.mode === "replace"
? authored.mechanics ?? []
: [...(authored?.mechanics ?? []), ...recipeAttacks, ...sourceAttacks];
const unique = [...new Map(mechanics.map((attack) => [attack.id, attack])).values()];
return [basicAttack, ...unique];
}
function entityFor(template, actor, sourceSpells, spellCatalog) {
function entityFor(template, actor, sourceSpells, spellCatalog, bossMechanics) {
const boss = template.classification === "boss";
const defaults = recipe.combatDefaults ?? {};
const combat = template.combat ?? {};
const attacks = attacksFor(template, sourceSpells, spellCatalog, bossMechanics);
return {
id: runtimePrefix + "-" + template.id,
name: template.name,
@@ -481,7 +492,8 @@ function entityFor(template, actor, sourceSpells, spellCatalog) {
damageMultiplier: combat.damageMultiplier ?? (boss ? defaults.bossDamageMultiplier ?? 1.3 : defaults.damageMultiplier ?? 1),
moveSpeed: combat.moveSpeed ?? (boss ? defaults.bossMoveSpeed ?? 2.25 : defaults.moveSpeed ?? 2.15),
leashRange: combat.leashRange ?? (boss ? defaults.bossLeashRange ?? 32 : defaults.leashRange ?? 24),
attacks: attacksFor(template, sourceSpells, spellCatalog),
...(boss ? { mechanicIds: attacks.slice(1).map((attack) => attack.id) } : {}),
attacks,
},
visual: {
primaryColor: template.primaryColor,
@@ -537,12 +549,14 @@ async function generatePopulation() {
requireFile(manifestFile, "Run build-population.mjs --actors-only first."),
requireFile(resolveProject(recipe.files.environmentMetadata), "Build the environment first."),
requireFile(spellCatalogFile, "Run npm run runewaker:spells:refresh once to create the portable spell catalog."),
requireFile(bossMechanicsFile, "Run npm run runewaker:bosses:refresh to create the boss mechanic catalog."),
]);
const [snapshot, manifest, environment, spellCatalogSnapshot] = await Promise.all([
const [snapshot, manifest, environment, spellCatalogSnapshot, bossMechanics] = await Promise.all([
readFile(snapshotFile, "utf8").then(JSON.parse),
readFile(manifestFile, "utf8").then(JSON.parse),
readFile(resolveProject(recipe.files.environmentMetadata), "utf8").then(JSON.parse),
readFile(spellCatalogFile, "utf8").then(JSON.parse),
readFile(bossMechanicsFile, "utf8").then(JSON.parse),
]);
if (spellCatalogSnapshot.status !== "portable"
|| spellCatalogSnapshot.safety?.runtimeSqlDependency !== false
@@ -619,6 +633,7 @@ async function generatePopulation() {
actorForTemplate(template),
[...(sourceSpellsByTemplate.get(template.id)?.values() ?? [])],
spellCatalog,
bossMechanics,
);
}
const spawn = {
+3
View File
@@ -36,6 +36,7 @@ Usage:
node scripts/runewaker-pipeline/cli.mjs actors --recipe <population.json>
node scripts/runewaker-pipeline/cli.mjs population --recipe <population.json>
node scripts/runewaker-pipeline/cli.mjs mechanics --recipe <population.json>
node scripts/runewaker-pipeline/cli.mjs boss-mechanics
node scripts/runewaker-pipeline/cli.mjs offline --recipe <population.json>
node scripts/runewaker-pipeline/cli.mjs build --recipe <population.json>
node scripts/runewaker-pipeline/cli.mjs validate --recipe <population.json>
@@ -73,6 +74,8 @@ if (command === "help" || command === "--help" || command === "-h") {
await run("build-population.mjs", argv);
} else if (command === "mechanics") {
await run("extract-mechanics.mjs", argv);
} else if (command === "boss-mechanics") {
await run("import-boss-mechanics.mjs", argv);
} else if (command === "validate") {
await run("validate-instance.mjs", argv);
} else if (command === "prepare-all") {
@@ -165,6 +165,28 @@ for (const recipe of recipes) {
assignmentsBySpell.set(Number(sourceSpell.id), assignments);
}
}
}
const luaRoot = path.join(sourceRoot, recipes[0].source.luaRoot ?? "Resource/luascript");
const luaNames = (await readdir(luaRoot)).filter((name) => name.endsWith(".lua")).sort();
for (const fileName of luaNames) {
const source = await readFile(path.join(luaRoot, fileName), "utf8");
const executableSource = source
.replace(/--\[\[[\s\S]*?\]\]/g, "")
.split(/\r?\n/)
.map((line) => line.replace(/--.*$/, ""))
.join("\n");
for (const match of executableSource.matchAll(/\b49\d{4}\b/g)) {
const spellId = Number(match[0]);
const assignments = assignmentsBySpell.get(spellId) ?? new Map();
const key = "lua:" + fileName + ":" + spellId;
assignments.set(key, {
sourceKind: "lua-reference",
sourceFile: fileName,
});
assignmentsBySpell.set(spellId, assignments);
}
}
const spells = [];
@@ -70,7 +70,7 @@ for (const dungeon of dungeons) {
String(actorManifest.proceduralFallbacks.length)
+ " runtime/dynamic actor containers use HealerMan procedural visuals because their ROS files expose no static triangles.",
] : []),
"Mechanics candidates are source-audited; complex doors, summons, phases, patrol scripts, and loot remain review-required.",
"Boss combat kits are compiled from RuneWaker spell/script evidence; complex doors, patrol scripts, and loot remain review-required.",
];
const definition = {
schemaVersion: "__SCHEMA__",
@@ -0,0 +1,509 @@
#!/usr/bin/env node
import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import {
pipelineDirectory,
projectPath,
projectRoot,
readJson,
sourceRootFor,
} from "./lib/recipe.mjs";
const recipesDirectory = path.join(pipelineDirectory, "recipes");
const mechanicCatalogFile = path.join(projectRoot, "dungeon-pipeline", "boss-mechanics.json");
const spellCatalogFile = path.join(
pipelineDirectory,
"snapshots",
"runewaker-npc-spell-catalog.json",
);
const recipeNames = (await readdir(recipesDirectory))
.filter((name) => name.endsWith("-population.json"))
.sort();
function slugify(value) {
return String(value ?? "")
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-zA-Z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();
}
function cleanLua(source) {
return source
.replace(/--\[\[[\s\S]*?\]\]/g, "")
.split(/\r?\n/)
.map((line) => line.replace(/--.*$/, ""))
.join("\n");
}
function functionIndex(luaSources) {
const functions = new Map();
for (const source of luaSources) {
const cleaned = cleanLua(source.text);
const matches = [...cleaned.matchAll(/^\s*function\s+([A-Za-z_][A-Za-z0-9_:.]*)\s*\(/gm)];
for (let index = 0; index < matches.length; index += 1) {
const match = matches[index];
const next = matches[index + 1];
functions.set(match[1], {
file: source.file,
body: cleaned.slice(match.index, next?.index ?? cleaned.length),
});
}
}
return functions;
}
function rootName(value) {
return String(value ?? "").trim().match(/^([A-Za-z_][A-Za-z0-9_:.]*)/)?.[1] ?? null;
}
function reachableFunctions(index, roots) {
const queue = roots.filter(Boolean);
const visited = new Set();
const result = [];
while (queue.length) {
const name = queue.shift();
if (!name || visited.has(name)) continue;
visited.add(name);
const definition = index.get(name);
if (!definition) continue;
result.push({ name, ...definition });
for (const match of definition.body.matchAll(/\b([A-Za-z_][A-Za-z0-9_:.]*)\s*\(/g)) {
if (index.has(match[1]) && !visited.has(match[1])) queue.push(match[1]);
}
for (const callbackCall of definition.body.matchAll(/\b(?:BeginPlot|SetPlot)\s*\([^\n]*/g)) {
for (const match of callbackCall[0].matchAll(/["']([A-Za-z_][A-Za-z0-9_:.]*)["']/g)) {
if (index.has(match[1]) && !visited.has(match[1])) queue.push(match[1]);
}
}
}
return result;
}
function telegraphFor(spell, delivery, target, range) {
const name = spell.name ?? "";
const effectRadius = Math.max(3, Math.min(15, Number(spell.source?.effectRange ?? 0) * 0.1));
if (/\b(?:breath|cleave|frontal|sweep|fan)\b/i.test(name)) {
return {
delivery: "area",
target: "nearby-party",
radius: undefined,
telegraph: {
shape: "cone",
anchor: "source",
warningMs: 1800,
length: Math.max(6, range),
angleDegrees: /breath/i.test(name) ? 70 : 95,
},
};
}
if (/\b(?:beam|charge|line|lance)\b/i.test(name)) {
return {
delivery: "area",
target: "nearby-party",
radius: undefined,
telegraph: {
shape: "line",
anchor: "source",
warningMs: 1800,
length: Math.max(7, range),
width: Math.max(2, effectRadius * 0.45),
},
};
}
const area = Number(spell.source?.rangeType ?? 0) !== 0
|| Number(spell.source?.effectRange ?? 0) >= 30
|| /\b(?:area|group|storm|rain|nova|vortex|wave|burst|explosion|whirl|spin|quake|shock)\b/i.test(name);
if (!area) return { delivery, target, radius: undefined, telegraph: undefined };
const targetAnchored = /\b(?:rain|meteor|eruption|ground|barrage)\b/i.test(name)
&& Number(spell.source?.targetType ?? 0) !== 0;
return {
delivery: "area",
target: "nearby-party",
radius: effectRadius,
telegraph: {
shape: "circle",
anchor: targetAnchored ? "target" : "source",
warningMs: 1800,
radius: effectRadius,
},
};
}
function damageMechanic(entityId, spell) {
const runtime = spell.runtime;
const spatial = telegraphFor(spell, runtime.delivery, runtime.target, runtime.range);
const castTimeMs = Math.max(
spatial.telegraph?.warningMs ?? 0,
Math.round(Math.max(0, Number(spell.source?.spellTimeSeconds ?? 0)) * 1000),
);
return {
id: entityId + "-spell-" + spell.spellId,
name: spell.name,
spellId: spell.spellId,
delivery: spatial.delivery,
target: spatial.target,
school: runtime.school,
animation: runtime.animation,
castTimeMs,
range: Math.max(runtime.range, spatial.radius ?? 0),
cooldownMs: runtime.cooldownMs,
initialCooldownMs: [runtime.cooldownMs, runtime.cooldownMs],
repeatCooldownMs: [runtime.cooldownMs, runtime.cooldownMs],
damageMultiplier: runtime.damageMultiplier,
...(spatial.radius === undefined ? {} : { radius: spatial.radius }),
...(spatial.telegraph ? { telegraph: spatial.telegraph } : {}),
};
}
function controlMechanic(entityId, spell) {
const identity = [spell.name, ...(spell.components ?? []).map((component) => component.name)]
.filter(Boolean)
.join(" ");
const mechanic = /fear|terr(?:or|if)/i.test(identity)
? "fear"
: /sleep/i.test(identity)
? "sleep"
: /root|catch|prison|immobili/i.test(identity)
? "root"
: /silence/i.test(identity)
? "silence"
: /stun|paraly/i.test(identity)
? "stun"
: null;
if (!mechanic) return null;
const cooldownMs = Math.max(4_000, Math.min(
25_000,
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 12_000,
));
const range = Math.max(4.5, Math.min(
30,
Number(spell.source?.attackDistance ?? 0) * 0.1 || 18,
));
const spatial = telegraphFor(spell, "projectile", "primary", range);
return {
id: entityId + "-control-" + spell.spellId,
name: spell.name,
delivery: spatial.delivery,
target: spatial.target,
school: "shadow",
animation: "cast",
castTimeMs: Math.max(1800, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
range,
cooldownMs,
initialCooldownMs: [cooldownMs, cooldownMs],
repeatCooldownMs: [cooldownMs, cooldownMs],
damageMultiplier: 0,
...(spatial.radius === undefined ? {} : { radius: spatial.radius }),
...(spatial.telegraph ? { telegraph: spatial.telegraph } : {}),
effect: { kind: "control", mechanic, durationMs: 3_000 },
};
}
function auraMechanic(entityId, spell) {
if (Number(spell.source?.targetType ?? -1) !== 0) return null;
const namedComponent = (spell.components ?? []).find((component) => (
component.name && Number(component.magicFunc) === 1
));
if (!namedComponent && /^RuneWaker Spell /i.test(spell.name ?? "")) return null;
const cooldownMs = Math.max(6_000, Math.min(
45_000,
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 15_000,
));
return {
id: entityId + "-aura-" + spell.spellId,
name: spell.name,
delivery: "area",
target: "self",
school: "arcane",
animation: "cast",
castTimeMs: Math.max(1_200, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
range: 0,
cooldownMs,
initialCooldownMs: [cooldownMs, cooldownMs],
repeatCooldownMs: [cooldownMs, cooldownMs],
damageMultiplier: 0,
effect: {
kind: "aura",
aura: slugify(namedComponent?.name ?? spell.name),
durationMs: Math.min(12_000, Math.max(4_000, Math.round(cooldownMs * 0.5))),
},
};
}
function targetAuraMechanic(entityId, spell) {
if (Number(spell.source?.targetType ?? 0) === 0) return null;
const namedComponent = (spell.components ?? []).find((component) => (
component.name && Number(component.magicFunc) === 1
));
if (!namedComponent) return null;
const cooldownMs = Math.max(6_000, Math.min(
30_000,
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 12_000,
));
const range = Math.max(4.5, Math.min(
30,
Number(spell.source?.attackDistance ?? 0) * 0.1 || 18,
));
const spatial = telegraphFor(spell, "projectile", "primary", range);
return {
id: entityId + "-debuff-" + spell.spellId,
name: spell.name,
delivery: spatial.delivery,
target: spatial.target,
school: "arcane",
animation: "cast",
castTimeMs: Math.max(1_800, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
range,
cooldownMs,
initialCooldownMs: [cooldownMs, cooldownMs],
repeatCooldownMs: [cooldownMs, cooldownMs],
damageMultiplier: 0,
...(spatial.radius === undefined ? {} : { radius: spatial.radius }),
...(spatial.telegraph ? { telegraph: spatial.telegraph } : {}),
effect: {
kind: "target-aura",
aura: slugify(namedComponent.name),
durationMs: Math.min(10_000, Math.max(4_000, Math.round(cooldownMs * 0.5))),
},
};
}
function scriptPhaseMechanics(entityId, bodies) {
const thresholds = [...new Set(bodies.flatMap((body) => (
[...body.matchAll(/\bHPPercent\s*<=\s*(\d+(?:\.\d+)?)\b/g)].map((match) => {
const raw = Number(match[1]);
return Math.round(raw <= 1 ? raw * 100 : raw);
})
)).filter((value) => value > 0 && value < 100))].sort((left, right) => right - left);
return thresholds.map((threshold) => ({
id: entityId + "-phase-" + threshold,
name: "Phase Change",
delivery: "area",
target: "self",
school: "arcane",
animation: "cast",
castTimeMs: 1_200,
interruptible: false,
range: 0,
cooldownMs: 1_000,
initialCooldownMs: [0, 0],
repeatCooldownMs: [1_000, 1_000],
maximumCasts: 1,
damageMultiplier: 0,
effect: { kind: "aura", aura: "phase-" + threshold, durationMs: 5_000 },
condition: { kind: "self-health-percent", minimum: 0, maximum: threshold },
}));
}
function summonMechanic(entityId, spell) {
const summon = (spell.components ?? []).find((component) => Number(component.summonCreatureId) > 0);
if (!summon) return null;
const cooldownMs = Math.max(8_000, Math.min(
40_000,
Number(spell.source?.cooldownSeconds ?? 0) * 1000 || 20_000,
));
return {
id: entityId + "-summon-" + spell.spellId,
name: spell.name,
delivery: "area",
target: "self",
school: "arcane",
animation: "cast",
castTimeMs: Math.max(1_800, Math.round(Number(spell.source?.spellTimeSeconds ?? 0) * 1000)),
interruptible: false,
range: 0,
cooldownMs,
initialCooldownMs: [cooldownMs, cooldownMs],
repeatCooldownMs: [cooldownMs, cooldownMs],
damageMultiplier: 0,
effect: { kind: "summon", creatureEntry: summon.summonCreatureId, count: 1 },
};
}
function scriptSummons(entityId, bodies) {
const joined = bodies.join("\n");
if (!/\b(?:CallMonster|CreateNPC|CreateMonster)/i.test(joined)) return [];
const constants = new Map([...joined.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(1\d{5})\b/g)]
.map((match) => [match[1], Number(match[2])]));
const creatureEntry = [...constants.entries()]
.find(([name]) => /monster|mob|summon/i.test(name))?.[1];
if (!creatureEntry) return [];
const count = Math.max(1, Math.min(6, Number(
[...joined.matchAll(/\b(?:MonsterNumber|MobNumber|SummonNumber)\w*\s*=\s*(\d+)\b/gi)][0]?.[1] ?? 1,
)));
const thresholds = [...new Set([...joined.matchAll(/\bHPPercent\s*<=\s*(0?\.\d+|1(?:\.0+)?)\b/g)]
.map((match) => Math.round(Number(match[1]) * 100))
.filter((value) => value > 0 && value < 100))].sort((left, right) => right - left);
const events = thresholds.length ? thresholds : [null];
return events.map((threshold, index) => ({
id: entityId + "-summon-" + creatureEntry + (threshold ? "-at-" + threshold : ""),
name: threshold ? "Reinforcements at " + threshold + "%" : "Call Reinforcements",
delivery: "area",
target: "self",
school: "physical",
animation: "cast",
castTimeMs: 1_800,
interruptible: false,
range: 0,
cooldownMs: threshold ? 1_000 : 20_000,
initialCooldownMs: threshold ? [0, 0] : [8_000, 8_000],
repeatCooldownMs: threshold ? [1_000, 1_000] : [20_000, 20_000],
...(threshold ? { maximumCasts: 1 } : {}),
damageMultiplier: 0,
effect: { kind: "summon", creatureEntry, count },
...(threshold ? {
condition: { kind: "self-health-percent", minimum: 0, maximum: threshold },
} : {}),
_sourceOrder: index,
})).map(({ _sourceOrder, ...mechanic }) => mechanic);
}
function sourceGapMechanic(entityId, name, archetype) {
const caster = /mage|witch|goddess|disciple|tree|seeker|guardian/i.test(name);
if (caster) {
return {
id: entityId + "-source-gap-burst",
name: "Arcane Burst",
delivery: "area",
target: "nearby-party",
school: "arcane",
animation: "cast",
castTimeMs: 2_000,
range: 12,
cooldownMs: 12_000,
initialCooldownMs: [8_000, 8_000],
repeatCooldownMs: [12_000, 12_000],
damageMultiplier: 0.82,
radius: 8,
telegraph: { shape: "circle", anchor: "source", warningMs: 2_000, radius: 8 },
};
}
return {
id: entityId + "-source-gap-sweep",
name: archetype === "ooze" ? "Corrosive Sweep" : "Heavy Sweep",
delivery: "area",
target: "nearby-party",
school: archetype === "ooze" ? "nature" : "physical",
animation: "attack",
castTimeMs: 1_800,
range: 7,
cooldownMs: 10_000,
initialCooldownMs: [7_000, 7_000],
repeatCooldownMs: [10_000, 10_000],
damageMultiplier: 1.1,
telegraph: {
shape: "cone",
anchor: "source",
warningMs: 1_800,
length: 7,
angleDegrees: 100,
},
};
}
const spellCatalog = await readJson(spellCatalogFile);
const spellsById = new Map(spellCatalog.spells.map((spell) => [Number(spell.spellId), spell]));
const catalog = await readJson(mechanicCatalogFile);
const runewakerBosses = {};
let sourceBacked = 0;
let sourceGap = 0;
const firstRecipe = await readJson(path.join(recipesDirectory, recipeNames[0]));
const sharedSourceRoot = sourceRootFor(firstRecipe);
const sharedLuaRoot = path.join(
sharedSourceRoot,
firstRecipe.source.luaRoot ?? "Resource/luascript",
);
const sharedLuaSources = [];
for (const file of (await readdir(sharedLuaRoot)).filter((name) => name.endsWith(".lua")).sort()) {
sharedLuaSources.push({ file, text: await readFile(path.join(sharedLuaRoot, file), "utf8") });
}
const sharedFunctions = functionIndex(sharedLuaSources);
for (const recipeName of recipeNames) {
const recipe = await readJson(path.join(recipesDirectory, recipeName));
const snapshot = await readJson(path.resolve(projectRoot, recipe.files.snapshot));
const rowsByTemplate = new Map();
for (const row of snapshot.rows ?? []) {
if (row.classification !== "boss") continue;
const rows = rowsByTemplate.get(Number(row.templateId)) ?? [];
rows.push(row);
rowsByTemplate.set(Number(row.templateId), rows);
}
for (const template of recipe.templates.filter((entry) => entry.classification === "boss")) {
const rows = rowsByTemplate.get(Number(template.id)) ?? [];
if (!rows.length) continue;
const roots = [...new Set(rows.flatMap((row) => [
rootName(row.source?.autoPlot),
rootName(row.source?.plotClassName),
...Object.values(row.source?.templateScripts ?? {}).map(rootName),
]).filter(Boolean))];
const reachable = reachableFunctions(sharedFunctions, roots);
const bodies = reachable.map((entry) => entry.body);
const scriptSpellIds = [...new Set(bodies.flatMap((body) => (
[...body.matchAll(/\b49\d{4}\b/g)].map((match) => Number(match[0]))
)))];
const templateSpellIds = [...new Set(rows.flatMap((row) => (
(row.source?.spells ?? []).map((spell) => Number(spell.id)).filter((id) => id > 0)
)))];
const spellIds = [...new Set([...templateSpellIds, ...scriptSpellIds])].sort((a, b) => a - b);
const entityId = (recipe.runtimeIdPrefix ?? "rw-" + recipe.dungeonId) + "-" + template.id;
const mechanics = [];
for (const spellId of spellIds) {
const spell = spellsById.get(spellId);
if (!spell) continue;
if (spell.mappingStatus === "damage-family" && spell.runtime) {
mechanics.push(damageMechanic(entityId, spell));
continue;
}
const summon = summonMechanic(entityId, spell);
if (summon) {
mechanics.push(summon);
continue;
}
const control = controlMechanic(entityId, spell);
if (control) {
mechanics.push(control);
continue;
}
const aura = auraMechanic(entityId, spell);
if (aura) {
mechanics.push(aura);
continue;
}
const targetAura = targetAuraMechanic(entityId, spell);
if (targetAura) mechanics.push(targetAura);
}
mechanics.push(...scriptSummons(entityId, bodies));
mechanics.push(...scriptPhaseMechanics(entityId, bodies));
const uniqueMechanics = [...new Map(mechanics.map((mechanic) => [mechanic.id, mechanic])).values()];
const fallback = uniqueMechanics.length === 0;
if (fallback) uniqueMechanics.push(sourceGapMechanic(entityId, template.name, template.archetype));
if (fallback) sourceGap += 1;
else sourceBacked += 1;
runewakerBosses[entityId] = {
name: template.name,
mode: "replace",
source: {
kind: fallback ? "healer-man-source-gap" : "runewaker",
dungeonId: recipe.dungeonId,
templateId: template.id,
roots,
functions: reachable.map((entry) => entry.name),
spellIds,
},
mechanics: uniqueMechanics,
};
}
}
catalog.bosses = Object.fromEntries([
...Object.entries(catalog.bosses).filter(([key]) => !key.startsWith("rw-")),
...Object.entries(runewakerBosses).sort(([left], [right]) => left.localeCompare(right)),
]);
await writeFile(mechanicCatalogFile, JSON.stringify(catalog, null, 2) + "\n", "utf8");
console.log("[runewaker-boss-mechanics] wrote " + Object.keys(runewakerBosses).length + " boss kits.");
console.log("[runewaker-boss-mechanics] source-backed: " + sourceBacked + "; source-gap fallback: " + sourceGap + ".");
console.log("[runewaker-boss-mechanics] " + projectPath(mechanicCatalogFile));
File diff suppressed because it is too large. Load diff