Release Healer Man 0.1.14
This commit is contained in:
1 parent
67d7a8ebe7
commit
7b41b102fa
147 files changed
+169892
-1342
No files matched your search
@@ -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 = {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user