Release Healer Man 0.1.21
This commit is contained in:
1 parent
699fc858c9
commit
f6f2375145
210 files changed
+976039
-6247
No files matched your search
@@ -28,6 +28,7 @@ import {
|
||||
import {
|
||||
nextTiledNavMeshSize,
|
||||
planNavigationBuild,
|
||||
shouldRetryFailedTiledBuildWithMonotone,
|
||||
shouldUseMonotonePartition,
|
||||
validateTiledBuild,
|
||||
} from "./recast-policy.mjs";
|
||||
@@ -275,9 +276,11 @@ export async function bakeNavigationFromCollisionAssets({
|
||||
const meshes = scenes.flatMap(meshesIn);
|
||||
if (!meshes.length) throw new Error(`${slug}: collision chunks contain no triangle meshes.`);
|
||||
const input = inspectNavigationInput(meshes);
|
||||
let build = planNavigationBuild(input.bounds, settings.cellSize, {
|
||||
tileSize: settings.tileSize,
|
||||
const navigationPlan = (tileSize) => planNavigationBuild(input.bounds, settings.cellSize, {
|
||||
tileSize,
|
||||
...(settings.forceTiled === true ? { maxSoloAxis: 0, maxSoloCells: 0 } : {}),
|
||||
});
|
||||
let build = navigationPlan(settings.tileSize);
|
||||
const generatorConfig = {
|
||||
cs: settings.cellSize,
|
||||
ch: settings.cellHeight,
|
||||
@@ -325,7 +328,20 @@ export async function bakeNavigationFromCollisionAssets({
|
||||
retainTileIntermediates,
|
||||
);
|
||||
}
|
||||
if (!generated.success) throw new Error(`${slug}: Recast generation failed: ${generated.error}`);
|
||||
if (!generated.success) {
|
||||
buildAttempts.push({
|
||||
partition: tiledPartition,
|
||||
tileSize: build.tileSize,
|
||||
tileCount: build.tileCount,
|
||||
valid: false,
|
||||
failures: [{ x: null, y: null, message: generated.error }],
|
||||
});
|
||||
if (shouldRetryFailedTiledBuildWithMonotone(generated.error, tiledPartition)) {
|
||||
tiledPartition = "monotone";
|
||||
continue;
|
||||
}
|
||||
throw new Error(`${slug}: Recast generation failed: ${generated.error}`);
|
||||
}
|
||||
tiledValidation = validateTiledBuild(generated.intermediates, {
|
||||
errorCategory: Recast.RC_LOG_ERROR,
|
||||
warningCategory: Recast.RC_LOG_WARNING,
|
||||
@@ -351,9 +367,7 @@ export async function bakeNavigationFromCollisionAssets({
|
||||
releaseTiledIntermediates(generated.intermediates);
|
||||
generated.navMesh.destroy();
|
||||
generated = null;
|
||||
build = planNavigationBuild(input.bounds, settings.cellSize, {
|
||||
tileSize: nextTileSize,
|
||||
});
|
||||
build = navigationPlan(nextTileSize);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
@@ -364,9 +378,7 @@ export async function bakeNavigationFromCollisionAssets({
|
||||
generated.navMesh.destroy();
|
||||
generated = null;
|
||||
tiledPartition = "monotone";
|
||||
build = planNavigationBuild(input.bounds, settings.cellSize, {
|
||||
tileSize: settings.tileSize,
|
||||
});
|
||||
build = navigationPlan(settings.tileSize);
|
||||
continue;
|
||||
}
|
||||
const sample = tiledValidation.failures
|
||||
|
||||
@@ -146,3 +146,8 @@ export function shouldUseMonotonePartition(validation) {
|
||||
(failure) => /^rcBuildRegions: \d+ overlapping regions\.$/.test(failure.message),
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldRetryFailedTiledBuildWithMonotone(error, partition) {
|
||||
return partition === "watershed"
|
||||
&& /failed to triangulate contours/i.test(String(error ?? ""));
|
||||
}
|
||||
@@ -25,16 +25,31 @@ async function main() {
|
||||
new Uint8Array(fs.readFileSync(file.path)),
|
||||
{ uri: file.file, maxIssues: 1_000 },
|
||||
);
|
||||
const validatorMessages = report.issues.messages;
|
||||
const hasBasisu = validatorMessages.some((issue) => (
|
||||
issue.code === "UNSUPPORTED_EXTENSION" && issue.message.includes("'KHR_texture_basisu'")
|
||||
));
|
||||
const actionable = validatorMessages.filter((issue) => {
|
||||
if (issue.code === "UNSUPPORTED_EXTENSION"
|
||||
&& /'(?:EXT_mesh_gpu_instancing|KHR_texture_basisu)'/.test(issue.message)) return false;
|
||||
if (issue.code === "VALUE_NOT_IN_LIST"
|
||||
&& hasBasisu
|
||||
&& /^\/images\/\d+\/mimeType$/.test(issue.pointer ?? "")
|
||||
&& issue.message.includes("'image/ktx2'")) return false;
|
||||
return true;
|
||||
});
|
||||
rows.push({
|
||||
file: file.file,
|
||||
errors: report.issues.numErrors,
|
||||
warnings: report.issues.numWarnings,
|
||||
errors: actionable.filter((issue) => issue.severity === 0).length,
|
||||
warnings: actionable.filter((issue) => issue.severity === 1).length,
|
||||
ignoredKnownToolingIssues: validatorMessages.length - actionable.length,
|
||||
});
|
||||
}
|
||||
const result = {
|
||||
files: rows.length,
|
||||
errors: rows.reduce((sum, row) => sum + row.errors, 0),
|
||||
warnings: rows.reduce((sum, row) => sum + row.warnings, 0),
|
||||
ignoredKnownToolingIssues: rows.reduce((sum, row) => sum + row.ignoredKnownToolingIssues, 0),
|
||||
nonGreen: rows.filter((row) => row.errors || row.warnings),
|
||||
};
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
|
||||
@@ -47,15 +47,23 @@ reviewed mechanics passes.
|
||||
|
||||
## Coordinate conversion
|
||||
|
||||
RuneWaker server positions are world coordinates. The environment ROS is
|
||||
placed by one WDB descriptor. Spawns must be moved into that ROS's local frame
|
||||
before the RuneWaker-to-Three coordinate conversion:
|
||||
RuneWaker server positions are world coordinates. The reviewed primary WDB
|
||||
descriptor remains the coordinate anchor, while every static and reviewed
|
||||
dynamic WDB resource is assembled relative to that anchor. Spawns use:
|
||||
|
||||
```text
|
||||
sourceLocal = inverse(primaryWdbPlacement) * serverPosition
|
||||
threeLocal = (sourceLocal.x, sourceLocal.y, -sourceLocal.z) * metersPerSourceUnit
|
||||
```
|
||||
|
||||
Every resource placement uses the full matrix equivalent, including translation,
|
||||
quaternion rotation, and nonuniform scale:
|
||||
|
||||
```text
|
||||
threeMatrix = S * inverse(primaryTRS) * placementTRS * inverse(S)
|
||||
S = scale(metersPerSourceUnit, metersPerSourceUnit, -metersPerSourceUnit)
|
||||
```
|
||||
|
||||
The current calibrated scale is `0.1` meter per RuneWaker source unit. The
|
||||
authoritative test-dungeon table uses 360 degrees per turn:
|
||||
|
||||
@@ -283,12 +291,17 @@ Review:
|
||||
- collision chunks for decorative blockers;
|
||||
- Recast report and entrance projection;
|
||||
- WDB anchor in `import-metadata.json`;
|
||||
- complete placement accounting in `wdb-placement-manifest.json`;
|
||||
- source-evidenced dynamic placements are absent from static collision and have separately packaged runtime assets;
|
||||
- dynamic filename candidates remain static and review-required until a reviewed recipe rule exists;
|
||||
- Khronos validation output.
|
||||
|
||||
Secondary WDB doodads/effects remain audited. Repeated primary room placements
|
||||
and documented source-coordinate support/fallback geometry are automated; a
|
||||
complete arbitrary multi-ROS WDB scene still requires explicit placement,
|
||||
material, collision, and performance review.
|
||||
Every valid WDB ROS resource is exported once into
|
||||
`runewaker-export-work/resource-cache/<resource-key>`, then reused for all of its
|
||||
placements and across dungeon builds. Missing ROS files, non-ROS descriptors,
|
||||
export failures, and non-mesh particle/light/sound/script components are recorded
|
||||
instead of receiving substitute geometry. The preview and editable Blender file
|
||||
remain primary-ROS diagnostics; review the assembled GLB for final coverage.
|
||||
|
||||
### 6. Review and build creature actors
|
||||
|
||||
@@ -401,6 +414,13 @@ Each mechanic must specify trigger, targets, timing, reset behavior, persistence
|
||||
offline behavior, and multiplayer synchronization boundary before being marked
|
||||
implemented.
|
||||
|
||||
Run `npm run runewaker:audit:encounters` after changing source snapshots,
|
||||
mechanics evidence, generated populations, or the shared boss-mechanic catalog.
|
||||
Its JSON report is the exhaustive implementation ledger. A source-spawn row or
|
||||
evidence record may be promoted from
|
||||
`deferred-source-gap` only when an exact executable mapping and its clearing test
|
||||
are both present; a regex category or suggestive object name is not sufficient.
|
||||
|
||||
### 9. Register the dungeon
|
||||
|
||||
Use the generated environment metadata and population constants to create the
|
||||
@@ -443,6 +463,8 @@ errors, WebGL errors, and mobile/desktop performance.
|
||||
| `public/assets/creatures/<slug>/*` | Browser-shipping actor GLBs/manifest. |
|
||||
| `src/game/generated/*Population.generated.ts` | Runtime entities/spawns/objectives. |
|
||||
| `src/game/generated/*Mechanics.generated.ts` | Non-executable mechanics evidence summary. |
|
||||
| `scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.*` | Deterministic per-row/per-signal fidelity and blocker ledger. |
|
||||
| `scripts/runewaker-pipeline/RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.*` | Non-mutating WDB resource/placement coverage and unsupported-type audit. |
|
||||
|
||||
Generated runtime data must be reproducible from recipes plus portable snapshots
|
||||
without SQL or network access.
|
||||
@@ -462,13 +484,13 @@ without SQL or network access.
|
||||
|
||||
## Current deliberate limits
|
||||
|
||||
The generic scripts automate source discovery, primary environment conversion,
|
||||
The generic scripts automate source discovery, source-hash-validated multi-resource WDB environment conversion,
|
||||
portable population recovery, direct GR2 actor conversion, exact paperdoll
|
||||
assembly, source-safe direct-damage attacks, spawn generation, repeated-room
|
||||
assembly, source-anchor navigation, mechanics evidence extraction, runtime
|
||||
assembly, full placement transforms, static navigation, reviewed dynamic-object packaging, mechanics evidence extraction, runtime
|
||||
registration, and validation. They do not pretend that arbitrary Lua/AutoPlot
|
||||
code can be safely translated without review. Disconnected navmesh islands,
|
||||
doors, phases, summons, patrols, timers, interactions, loot semantics, arbitrary
|
||||
multi-ROS placement assembly, source-exact damage formulas, and multiplayer
|
||||
source behavior for unreviewed dynamic candidates, source-exact damage formulas, and multiplayer
|
||||
encounter synchronization remain explicit per-instance engineering work until
|
||||
implemented and playtested.
|
||||
@@ -3,11 +3,12 @@
|
||||
This pipeline converts preserved RuneWaker `.ros` dungeon assets into the same
|
||||
browser-facing GLB roles used by HealerMan's WoW dungeon pipeline:
|
||||
|
||||
1. the preserved Rune engine reads the WDB entity table and exports the primary ROS as an OBJ/MTL bridge;
|
||||
2. Microsoft `texconv` decodes the legacy DDS textures to PNG;
|
||||
3. Blender converts the Y-up source to textured visual and chunked collision GLBs;
|
||||
4. glTF Transform optimizes the visual asset with meshopt;
|
||||
5. HealerMan's Recast stage bakes navigation and the Khronos validator gates every GLB.
|
||||
1. the preserved Rune engine reads every WDB descriptor and exports each unique ROS once into a normalized-path cache validated by the source SHA-256;
|
||||
2. Microsoft `texconv` decodes each cached resource's legacy DDS textures to PNG;
|
||||
3. Blender converts the Y-up resources to reusable visual and collision GLBs;
|
||||
4. the assembler applies every placement's full translation/quaternion/scale transform relative to the reviewed primary WDB anchor;
|
||||
5. glTF Transform instances and optimizes the assembled visual with meshopt;
|
||||
6. HealerMan's Recast stage bakes static navigation and the Khronos validator gates every GLB.
|
||||
|
||||
Run the Forsaken Abbey pilot from the HealerMan root:
|
||||
|
||||
@@ -24,10 +25,29 @@ Optional environment variables:
|
||||
|
||||
The shipping environment is written to
|
||||
`src/assets/game/dungeons/forsaken-abbey` and registered as a playable
|
||||
dungeon. The build reads all 184 WDB entity descriptors,
|
||||
uses the primary ROS placement as the server-to-local anchor, reads the entrance
|
||||
from `Resource/luascript/03242.lua`, and requires that entrance to project within
|
||||
2 meters of the generated navmesh.
|
||||
dungeon. The build reads all 184 WDB entity descriptors, uses the primary ROS
|
||||
placement only as the server-to-local coordinate anchor, reads the entrance from
|
||||
`Resource/luascript/03242.lua`, and requires that entrance to project within 2
|
||||
meters of the generated navmesh.
|
||||
|
||||
All valid ROS placement resources are assembled by default. Use `--primary-only`
|
||||
only for a deliberate compatibility/debug build. The build writes
|
||||
`wdb-placement-manifest.json` with stable placement IDs, source TRS, the complete
|
||||
primary-local Three.js matrix, resource-cache keys, bounds, flags, asset status,
|
||||
and reviewed dynamic-object metadata. Static placements enter visual, collision,
|
||||
and navigation assets. Explicit dynamic placements ship separately so runtime
|
||||
state can move, hide, or replace them without baking their collision into the
|
||||
static world.
|
||||
|
||||
Audit the checked-in WDB coverage without regenerating GLBs:
|
||||
|
||||
```powershell
|
||||
npm run runewaker:audit:environment-assembly
|
||||
```
|
||||
|
||||
See
|
||||
[RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.md](./RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.md)
|
||||
for unsupported types and dynamic candidates that still need source review.
|
||||
|
||||
Population and creature assets are separate, offline-capable stages:
|
||||
|
||||
@@ -36,6 +56,15 @@ npm run runewaker:forsaken:actors
|
||||
npm run runewaker:forsaken:population
|
||||
```
|
||||
|
||||
Source-shaped combat coefficients and boss loot are refreshed independently:
|
||||
|
||||
```powershell
|
||||
npm run runewaker:spells:refresh
|
||||
npm run runewaker:loot:refresh
|
||||
```
|
||||
|
||||
See [RUNEWAKER_FORMULA_AND_LOOT.md](./RUNEWAKER_FORMULA_AND_LOOT.md) for the exact formula mapping boundary and [RUNEWAKER_LOOT_IMPORT_REPORT.md](./RUNEWAKER_LOOT_IMPORT_REPORT.md) for every boss's loot status.
|
||||
|
||||
Only an explicit forensic refresh connects to the isolated development SQL
|
||||
instance:
|
||||
|
||||
@@ -121,8 +150,10 @@ Spell conversion is deliberately fail-closed. Only a MagicObject component
|
||||
positively classified as direct HP damage becomes an executable HealerMan
|
||||
attack. Buffs, healing, summons, plot hooks, and unknown effects remain
|
||||
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.
|
||||
damage family, school, range, cooldown, source power fields, and source-code
|
||||
formula provenance. Live damage uses a labeled HealerMan baseline adapter because
|
||||
the required native ATK/MATK, DEF/MDEF, weapon, resistance, critical, level-rate,
|
||||
and global-rate inputs are not packaged; it is not represented as source-exact.
|
||||
|
||||
`runewaker:bosses:refresh` first rebuilds that portable spell snapshot, then
|
||||
statically follows each packaged boss's preserved AutoPlot/template callbacks
|
||||
@@ -165,6 +196,21 @@ accepts that reviewed override only as `bossObjectiveOrder: { kind:
|
||||
"source-authored", spawnIds: [...], evidence: [...] }`; it rejects missing
|
||||
evidence, duplicates, or an order that does not account for every boss spawn.
|
||||
|
||||
The same gameplay audit also regenerates the deterministic
|
||||
[RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md](./RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md)
|
||||
and its exhaustive JSON companion. The ledger records all deferred source spawn
|
||||
rows and direct mechanics evidence with stable IDs, source files and hashes,
|
||||
fidelity, implementation status, blocker, next work, and a clearing test.
|
||||
Category totals are overlapping review labels, never a claimed count of unique
|
||||
mechanics. Unproven behavior remains `deferred-source-gap` instead of being
|
||||
silently inferred from a name or regex match.
|
||||
|
||||
Run only that ledger and its pipeline tests with:
|
||||
|
||||
~~~powershell
|
||||
npm run runewaker:audit:encounters
|
||||
~~~
|
||||
|
||||
Run the component-level navigation review separately:
|
||||
|
||||
~~~powershell
|
||||
|
||||
@@ -13,7 +13,7 @@ Required top-level fields:
|
||||
- `title`: display/source title.
|
||||
- `source`: preserved WDB, primary ROS, config, and entrance evidence.
|
||||
- `coordinateSystem`: source axes, units, and direction units.
|
||||
- `navigation`: Recast settings.
|
||||
- `navigation`: Recast settings. `partition` may select `watershed` or `monotone`; `forceTiled` is an explicit source-geometry workaround when a compact solo heightfield cannot triangulate its contours.
|
||||
- `review`: human review states.
|
||||
- `exceptions`: known limitations retained in metadata.
|
||||
|
||||
@@ -27,6 +27,25 @@ Required top-level fields:
|
||||
- `entryLua` / `entryConstantPrefix`: authoritative entry constants; or
|
||||
- `entryCoordinates`: explicit position/direction with `reviewRequired`.
|
||||
|
||||
Optional `source.wdbAssembly` fields:
|
||||
|
||||
- `mode`: `all-ros` by default, or `primary-only` for a deliberate compatibility build.
|
||||
- `dynamicPlacements`: reviewed dynamic object selectors. Each entry requires a
|
||||
stable `id`, `kind`, `evidence`, and either an exact WDB `guid` or normalized
|
||||
`resource`; optional `initialState` and `collision` describe the runtime contract.
|
||||
|
||||
Valid ROS placements not selected as dynamic are static. Filename hints are
|
||||
reported for review but never infer stateful behavior. Non-ROS descriptors,
|
||||
invalid transforms, missing resources, and exporter failures remain explicit
|
||||
unsupported records in the placement manifest.
|
||||
|
||||
The full placement transform is preserved as:
|
||||
|
||||
```text
|
||||
threeMatrix = S * inverse(primaryTRS) * placementTRS * inverse(S)
|
||||
S = scale(metersPerSourceUnit, metersPerSourceUnit, -metersPerSourceUnit)
|
||||
```
|
||||
|
||||
## Population recipe
|
||||
|
||||
Required fields:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,121 @@
|
||||
# RuneWaker encounter implementation and fidelity ledger
|
||||
|
||||
Structural validation: **green**
|
||||
Content completion: **review-required**
|
||||
|
||||
This report is deterministic and offline. Its JSON companion inventories every deferred
|
||||
source spawn row and every direct mechanics evidence record. A regex category membership
|
||||
is a review label, not a unique mechanic; one evidence record may belong to several categories.
|
||||
|
||||
## Coverage
|
||||
|
||||
- 30 dungeon packages.
|
||||
- 4113 deferred source rows with stable spawn IDs.
|
||||
- 1524 unique mechanics evidence records: 329 Lua lines and 1195 template references.
|
||||
- 1146 overlapping category memberships; this number is intentionally not reported as mechanics.
|
||||
- 388 evidence records have an exact executable source-spell mapping.
|
||||
- 1136 evidence records remain source-review gaps.
|
||||
|
||||
## Status definitions
|
||||
|
||||
- `verified`: an exact source identifier is present in executable runtime data and checked by the audit.
|
||||
- `implemented`: executable behavior exists, but source fidelity is incomplete or explicitly authored.
|
||||
- `deferred-source-gap`: source context does not prove the full behavior; the generator never guesses it.
|
||||
- `deferred-asset-gap`: behavior is understood but a required asset is unavailable.
|
||||
- `deferred-runtime-gap`: behavior is understood but the runtime primitive is unavailable.
|
||||
- `excluded-non-gameplay`: a reviewed source item is confirmed to have no gameplay behavior.
|
||||
- `intentional-empty`: the authoritative packaged snapshot contains no population rows.
|
||||
|
||||
## Per-dungeon inventory
|
||||
|
||||
| Dungeon | Source rows | Deferred rows | Evidence | Verified | Source gaps | Bosses | Package status |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| aeternal-circle | 514 | 391 | 47 | 13 | 34 | 0 | deferred-source-gap |
|
||||
| barren-caves | 63 | 15 | 14 | 7 | 7 | 1 | verified |
|
||||
| cave-water-dragon | 144 | 47 | 15 | 2 | 13 | 2 | verified |
|
||||
| demon-stronghold | 439 | 256 | 38 | 12 | 26 | 1 | verified |
|
||||
| demon-stronghold-125 | 0 | 0 | 0 | 0 | 0 | 0 | intentional-empty |
|
||||
| dungeon-of-dalanis | 229 | 162 | 26 | 5 | 21 | 0 | deferred-source-gap |
|
||||
| forsaken-abbey | 251 | 101 | 77 | 0 | 77 | 8 | verified |
|
||||
| grafu-castle | 249 | 193 | 50 | 10 | 40 | 0 | deferred-source-gap |
|
||||
| hall-of-survivors | 363 | 201 | 84 | 34 | 50 | 9 | verified |
|
||||
| hoto | 401 | 94 | 95 | 28 | 67 | 5 | verified |
|
||||
| ice-dwarf-kingdom | 651 | 52 | 87 | 36 | 51 | 5 | verified |
|
||||
| kal-turok-hive | 4 | 3 | 10 | 4 | 6 | 0 | deferred-source-gap |
|
||||
| kalins-shrine | 289 | 124 | 294 | 17 | 277 | 5 | verified |
|
||||
| kawaks-tomb | 293 | 176 | 46 | 7 | 39 | 1 | verified |
|
||||
| kulech-bones-nest | 287 | 133 | 59 | 18 | 41 | 1 | verified |
|
||||
| lair-demon-dragon | 532 | 429 | 28 | 9 | 19 | 0 | deferred-source-gap |
|
||||
| mystic-altar | 398 | 147 | 68 | 26 | 42 | 3 | verified |
|
||||
| paspers-shrine | 255 | 31 | 22 | 6 | 16 | 5 | verified |
|
||||
| raksha-temple | 487 | 364 | 85 | 21 | 64 | 1 | verified |
|
||||
| sand-vortex | 2 | 1 | 2 | 0 | 2 | 1 | verified |
|
||||
| sardo-castle | 247 | 176 | 53 | 12 | 41 | 3 | verified |
|
||||
| the-origin | 510 | 322 | 65 | 29 | 36 | 2 | verified |
|
||||
| tomb-seven-heroes | 439 | 199 | 101 | 32 | 69 | 0 | deferred-source-gap |
|
||||
| treasure-trove | 685 | 188 | 81 | 34 | 47 | 3 | verified |
|
||||
| warnorken-arena | 40 | 39 | 2 | 0 | 2 | 0 | deferred-source-gap |
|
||||
| ystra-labyrinth-boss1 | 17 | 14 | 12 | 2 | 10 | 3 | verified |
|
||||
| ystra-labyrinth-boss2 | 3 | 2 | 3 | 1 | 2 | 1 | verified |
|
||||
| ystra-labyrinth-boss3 | 7 | 6 | 2 | 0 | 2 | 1 | verified |
|
||||
| zurhidon-stronghold | 400 | 247 | 58 | 23 | 35 | 0 | deferred-source-gap |
|
||||
| zurhidon-stronghold-124 | 0 | 0 | 0 | 0 | 0 | 0 | intentional-empty |
|
||||
|
||||
## Four explicit fallback bosses
|
||||
|
||||
| Boss | Runtime ID | Status | Fidelity | Blocker |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Necromage | `rw-fa-100070` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
|
||||
| Flowing Chaos | `rw-fa-100561` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
|
||||
| Cruel Eater | `rw-fa-100562` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
|
||||
| Hollow Shell | `rw-fa-100563` | implemented | healer-man-authored | Preserved source evidence does not expose an executable original combat kit. |
|
||||
|
||||
These attacks are intentionally playable and telegraphed HealerMan behavior. They are not
|
||||
presented as original RuneWaker mechanics until authoritative source behavior is recovered.
|
||||
|
||||
## Non-empty packages without confirmed boss objectives
|
||||
|
||||
- `aeternal-circle`: 514 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||
- `dungeon-of-dalanis`: 229 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||
- `grafu-castle`: 249 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||
- `kal-turok-hive`: 4 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||
- `lair-demon-dragon`: 532 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||
- `tomb-seven-heroes`: 439 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||
- `warnorken-arena`: 40 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||
- `zurhidon-stronghold`: 400 rows; The portable source package contains population rows but no source-confirmed boss membership. Next: Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.
|
||||
|
||||
## Intentional empty variants
|
||||
|
||||
- `demon-stronghold-125` (Zone 125): The authoritative packaged snapshot contains no active population rows.
|
||||
- `zurhidon-stronghold-124` (Zone 124): The authoritative packaged snapshot contains no active population rows.
|
||||
|
||||
## Evidence category memberships
|
||||
|
||||
| Category | Evidence records carrying the label |
|
||||
| --- | ---: |
|
||||
| death | 100 |
|
||||
| door | 23 |
|
||||
| interaction | 166 |
|
||||
| loot | 77 |
|
||||
| movement | 6 |
|
||||
| phase | 58 |
|
||||
| spell | 679 |
|
||||
| summon | 11 |
|
||||
| timer | 26 |
|
||||
|
||||
## Remaining work ledger
|
||||
|
||||
| Blocker group | Items | Clearing work |
|
||||
| --- | ---: | --- |
|
||||
| deferredSourceRows | 4113 | Review source semantics and add explicit source-spawn encounter mappings. |
|
||||
| unresolvedMechanicsEvidence | 1136 | Review surrounding Lua/template context; specify full trigger, effect, and reset behavior. |
|
||||
| adaptedSpellFormula | 388 | Recover native formula inputs before claiming coefficient-level fidelity. |
|
||||
| sourceGapFallbackBosses | 4 | Recover authoritative combat kits and replace only with source-backed modular mechanics. |
|
||||
| nonEmptyZeroBossPackages | 8 | Recover authoritative boss membership and objective order. |
|
||||
|
||||
The exhaustive per-item blocker, next-work instruction, clearing test, source IDs, source
|
||||
files, and SHA-256 hashes are in `RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json`.
|
||||
|
||||
## Validation issues
|
||||
|
||||
- None.
|
||||
@@ -0,0 +1,476 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedBy": "scripts/runewaker-pipeline/audit-environment-assembly.mjs",
|
||||
"deterministic": true,
|
||||
"validationStatus": "green",
|
||||
"reviewStatus": "review-required",
|
||||
"summary": {
|
||||
"dungeons": 30,
|
||||
"sourceDescriptors": 15976,
|
||||
"uniqueDungeonResources": 2799,
|
||||
"staticPlacements": 15976,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 107,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0
|
||||
},
|
||||
"unsupportedResourceTypes": [
|
||||
"WDB placement resources other than ROS are recorded but not converted.",
|
||||
"Particle, light, sound, camera, and script-only components absent from ROS mesh descriptors remain unsupported.",
|
||||
"Animated/stateful behavior remains unsupported until an exact reviewed dynamic-placement rule supplies its kind, initial state, collision policy, and evidence.",
|
||||
"A ROS that the preserved exporter cannot decode is skipped, recorded with its resource path and failure, and never replaced with guessed geometry."
|
||||
],
|
||||
"issues": [],
|
||||
"dungeons": [
|
||||
{
|
||||
"id": "aeternal-circle",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/aeternal-circle.json",
|
||||
"wdbReport": "src/assets/game/dungeons/aeternal-circle/wdb-report.json",
|
||||
"sourceDescriptors": 575,
|
||||
"uniqueResources": 182,
|
||||
"staticPlacements": 575,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 54,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "barren-caves",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/barren-caves.json",
|
||||
"wdbReport": "src/assets/game/dungeons/barren-caves/wdb-report.json",
|
||||
"sourceDescriptors": 1,
|
||||
"uniqueResources": 1,
|
||||
"staticPlacements": 1,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "cave-water-dragon",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/cave-water-dragon.json",
|
||||
"wdbReport": "src/assets/game/dungeons/cave-water-dragon/wdb-report.json",
|
||||
"sourceDescriptors": 121,
|
||||
"uniqueResources": 16,
|
||||
"staticPlacements": 121,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "demon-stronghold",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold.json",
|
||||
"wdbReport": "src/assets/game/dungeons/demon-stronghold/wdb-report.json",
|
||||
"sourceDescriptors": 90,
|
||||
"uniqueResources": 4,
|
||||
"staticPlacements": 90,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "demon-stronghold-125",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/demon-stronghold-125.json",
|
||||
"wdbReport": "src/assets/game/dungeons/demon-stronghold-125/wdb-report.json",
|
||||
"sourceDescriptors": 90,
|
||||
"uniqueResources": 4,
|
||||
"staticPlacements": 90,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "dungeon-of-dalanis",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/dungeon-of-dalanis.json",
|
||||
"wdbReport": "src/assets/game/dungeons/dungeon-of-dalanis/wdb-report.json",
|
||||
"sourceDescriptors": 652,
|
||||
"uniqueResources": 124,
|
||||
"staticPlacements": 652,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 5,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "forsaken-abbey",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/forsaken-abbey.json",
|
||||
"wdbReport": "src/assets/game/dungeons/forsaken-abbey/wdb-report.json",
|
||||
"sourceDescriptors": 184,
|
||||
"uniqueResources": 26,
|
||||
"staticPlacements": 184,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 1,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "grafu-castle",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/grafu-castle.json",
|
||||
"wdbReport": "src/assets/game/dungeons/grafu-castle/wdb-report.json",
|
||||
"sourceDescriptors": 899,
|
||||
"uniqueResources": 286,
|
||||
"staticPlacements": 899,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 17,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "hall-of-survivors",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/hall-of-survivors.json",
|
||||
"wdbReport": "src/assets/game/dungeons/hall-of-survivors/wdb-report.json",
|
||||
"sourceDescriptors": 296,
|
||||
"uniqueResources": 32,
|
||||
"staticPlacements": 296,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "hoto",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/hoto.json",
|
||||
"wdbReport": "src/assets/game/dungeons/hoto/wdb-report.json",
|
||||
"sourceDescriptors": 1627,
|
||||
"uniqueResources": 119,
|
||||
"staticPlacements": 1627,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "ice-dwarf-kingdom",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/ice-dwarf-kingdom.json",
|
||||
"wdbReport": "src/assets/game/dungeons/ice-dwarf-kingdom/wdb-report.json",
|
||||
"sourceDescriptors": 1054,
|
||||
"uniqueResources": 209,
|
||||
"staticPlacements": 1054,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "kal-turok-hive",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/kal-turok-hive.json",
|
||||
"wdbReport": "src/assets/game/dungeons/kal-turok-hive/wdb-report.json",
|
||||
"sourceDescriptors": 2,
|
||||
"uniqueResources": 2,
|
||||
"staticPlacements": 2,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 1,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "kalins-shrine",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/kalins-shrine.json",
|
||||
"wdbReport": "src/assets/game/dungeons/kalins-shrine/wdb-report.json",
|
||||
"sourceDescriptors": 943,
|
||||
"uniqueResources": 133,
|
||||
"staticPlacements": 943,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "kawaks-tomb",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/kawaks-tomb.json",
|
||||
"wdbReport": "src/assets/game/dungeons/kawaks-tomb/wdb-report.json",
|
||||
"sourceDescriptors": 573,
|
||||
"uniqueResources": 60,
|
||||
"staticPlacements": 573,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "kulech-bones-nest",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/kulech-bones-nest.json",
|
||||
"wdbReport": "src/assets/game/dungeons/kulech-bones-nest/wdb-report.json",
|
||||
"sourceDescriptors": 199,
|
||||
"uniqueResources": 52,
|
||||
"staticPlacements": 199,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "lair-demon-dragon",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/lair-demon-dragon.json",
|
||||
"wdbReport": "src/assets/game/dungeons/lair-demon-dragon/wdb-report.json",
|
||||
"sourceDescriptors": 530,
|
||||
"uniqueResources": 96,
|
||||
"staticPlacements": 530,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "mystic-altar",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/mystic-altar.json",
|
||||
"wdbReport": "src/assets/game/dungeons/mystic-altar/wdb-report.json",
|
||||
"sourceDescriptors": 2096,
|
||||
"uniqueResources": 48,
|
||||
"staticPlacements": 2096,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "paspers-shrine",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/paspers-shrine.json",
|
||||
"wdbReport": "src/assets/game/dungeons/paspers-shrine/wdb-report.json",
|
||||
"sourceDescriptors": 564,
|
||||
"uniqueResources": 143,
|
||||
"staticPlacements": 564,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "raksha-temple",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/raksha-temple.json",
|
||||
"wdbReport": "src/assets/game/dungeons/raksha-temple/wdb-report.json",
|
||||
"sourceDescriptors": 1028,
|
||||
"uniqueResources": 228,
|
||||
"staticPlacements": 1028,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 11,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "sand-vortex",
|
||||
"mode": "primary-only-procedural-fallback",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/sand-vortex.json",
|
||||
"wdbReport": "src/assets/game/dungeons/sand-vortex/wdb-report.json",
|
||||
"sourceDescriptors": 20,
|
||||
"uniqueResources": 14,
|
||||
"staticPlacements": 20,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "sardo-castle",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/sardo-castle.json",
|
||||
"wdbReport": "src/assets/game/dungeons/sardo-castle/wdb-report.json",
|
||||
"sourceDescriptors": 436,
|
||||
"uniqueResources": 132,
|
||||
"staticPlacements": 436,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "the-origin",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/the-origin.json",
|
||||
"wdbReport": "src/assets/game/dungeons/the-origin/wdb-report.json",
|
||||
"sourceDescriptors": 693,
|
||||
"uniqueResources": 220,
|
||||
"staticPlacements": 693,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 17,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "tomb-seven-heroes",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/tomb-seven-heroes.json",
|
||||
"wdbReport": "src/assets/game/dungeons/tomb-seven-heroes/wdb-report.json",
|
||||
"sourceDescriptors": 450,
|
||||
"uniqueResources": 56,
|
||||
"staticPlacements": 450,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "treasure-trove",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/treasure-trove.json",
|
||||
"wdbReport": "src/assets/game/dungeons/treasure-trove/wdb-report.json",
|
||||
"sourceDescriptors": 908,
|
||||
"uniqueResources": 193,
|
||||
"staticPlacements": 908,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 1,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "warnorken-arena",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/warnorken-arena.json",
|
||||
"wdbReport": "src/assets/game/dungeons/warnorken-arena/wdb-report.json",
|
||||
"sourceDescriptors": 256,
|
||||
"uniqueResources": 14,
|
||||
"staticPlacements": 256,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "ystra-labyrinth-boss1",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1.json",
|
||||
"wdbReport": "src/assets/game/dungeons/ystra-labyrinth-boss1/wdb-report.json",
|
||||
"sourceDescriptors": 3,
|
||||
"uniqueResources": 1,
|
||||
"staticPlacements": 3,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "ystra-labyrinth-boss2",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss2.json",
|
||||
"wdbReport": "src/assets/game/dungeons/ystra-labyrinth-boss2/wdb-report.json",
|
||||
"sourceDescriptors": 3,
|
||||
"uniqueResources": 1,
|
||||
"staticPlacements": 3,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "ystra-labyrinth-boss3",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss3.json",
|
||||
"wdbReport": "src/assets/game/dungeons/ystra-labyrinth-boss3/wdb-report.json",
|
||||
"sourceDescriptors": 3,
|
||||
"uniqueResources": 1,
|
||||
"staticPlacements": 3,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "zurhidon-stronghold",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold.json",
|
||||
"wdbReport": "src/assets/game/dungeons/zurhidon-stronghold/wdb-report.json",
|
||||
"sourceDescriptors": 840,
|
||||
"uniqueResources": 201,
|
||||
"staticPlacements": 840,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
},
|
||||
{
|
||||
"id": "zurhidon-stronghold-124",
|
||||
"mode": "all-ros",
|
||||
"recipe": "scripts/runewaker-pipeline/recipes/zurhidon-stronghold-124.json",
|
||||
"wdbReport": "src/assets/game/dungeons/zurhidon-stronghold-124/wdb-report.json",
|
||||
"sourceDescriptors": 840,
|
||||
"uniqueResources": 201,
|
||||
"staticPlacements": 840,
|
||||
"dynamicPlacements": 0,
|
||||
"dynamicReviewCandidates": 0,
|
||||
"unsupportedPlacements": 0,
|
||||
"missingSourceResources": 0,
|
||||
"missingResources": [],
|
||||
"unsupported": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
# RuneWaker WDB environment assembly audit
|
||||
|
||||
Structural validation: **green**
|
||||
Source-behavior review: **review-required**
|
||||
|
||||
This deterministic audit reads the checked-in WDB reports and environment recipes. It does
|
||||
not convert or replace shipping GLBs. The builder now supports every valid ROS placement
|
||||
resource with normalized-path model caching validated by source hashes and the complete translation/quaternion/scale
|
||||
transform relative to the reviewed primary placement.
|
||||
|
||||
## Coverage
|
||||
|
||||
- 30 dungeon WDB packages and 15976 source descriptors.
|
||||
- 2799 per-dungeon unique ROS resources (resources reused across dungeons share cache keys).
|
||||
- 15976 static placements and 0 explicitly reviewed dynamic placements.
|
||||
- 107 filename-based dynamic candidates remain static and review-required; names never prove behavior.
|
||||
- 0 unsupported placement records and 0 missing source ROS resources.
|
||||
|
||||
| Dungeon | Mode | Descriptors | Resources | Static | Dynamic | Dynamic review | Unsupported | Missing ROS |
|
||||
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| aeternal-circle | all-ros | 575 | 182 | 575 | 0 | 54 | 0 | 0 |
|
||||
| barren-caves | all-ros | 1 | 1 | 1 | 0 | 0 | 0 | 0 |
|
||||
| cave-water-dragon | all-ros | 121 | 16 | 121 | 0 | 0 | 0 | 0 |
|
||||
| demon-stronghold | all-ros | 90 | 4 | 90 | 0 | 0 | 0 | 0 |
|
||||
| demon-stronghold-125 | all-ros | 90 | 4 | 90 | 0 | 0 | 0 | 0 |
|
||||
| dungeon-of-dalanis | all-ros | 652 | 124 | 652 | 0 | 5 | 0 | 0 |
|
||||
| forsaken-abbey | all-ros | 184 | 26 | 184 | 0 | 1 | 0 | 0 |
|
||||
| grafu-castle | all-ros | 899 | 286 | 899 | 0 | 17 | 0 | 0 |
|
||||
| hall-of-survivors | all-ros | 296 | 32 | 296 | 0 | 0 | 0 | 0 |
|
||||
| hoto | all-ros | 1627 | 119 | 1627 | 0 | 0 | 0 | 0 |
|
||||
| ice-dwarf-kingdom | all-ros | 1054 | 209 | 1054 | 0 | 0 | 0 | 0 |
|
||||
| kal-turok-hive | all-ros | 2 | 2 | 2 | 0 | 1 | 0 | 0 |
|
||||
| kalins-shrine | all-ros | 943 | 133 | 943 | 0 | 0 | 0 | 0 |
|
||||
| kawaks-tomb | all-ros | 573 | 60 | 573 | 0 | 0 | 0 | 0 |
|
||||
| kulech-bones-nest | all-ros | 199 | 52 | 199 | 0 | 0 | 0 | 0 |
|
||||
| lair-demon-dragon | all-ros | 530 | 96 | 530 | 0 | 0 | 0 | 0 |
|
||||
| mystic-altar | all-ros | 2096 | 48 | 2096 | 0 | 0 | 0 | 0 |
|
||||
| paspers-shrine | all-ros | 564 | 143 | 564 | 0 | 0 | 0 | 0 |
|
||||
| raksha-temple | all-ros | 1028 | 228 | 1028 | 0 | 11 | 0 | 0 |
|
||||
| sand-vortex | primary-only-procedural-fallback | 20 | 14 | 20 | 0 | 0 | 0 | 0 |
|
||||
| sardo-castle | all-ros | 436 | 132 | 436 | 0 | 0 | 0 | 0 |
|
||||
| the-origin | all-ros | 693 | 220 | 693 | 0 | 17 | 0 | 0 |
|
||||
| tomb-seven-heroes | all-ros | 450 | 56 | 450 | 0 | 0 | 0 | 0 |
|
||||
| treasure-trove | all-ros | 908 | 193 | 908 | 0 | 1 | 0 | 0 |
|
||||
| warnorken-arena | all-ros | 256 | 14 | 256 | 0 | 0 | 0 | 0 |
|
||||
| ystra-labyrinth-boss1 | all-ros | 3 | 1 | 3 | 0 | 0 | 0 | 0 |
|
||||
| ystra-labyrinth-boss2 | all-ros | 3 | 1 | 3 | 0 | 0 | 0 | 0 |
|
||||
| ystra-labyrinth-boss3 | all-ros | 3 | 1 | 3 | 0 | 0 | 0 | 0 |
|
||||
| zurhidon-stronghold | all-ros | 840 | 201 | 840 | 0 | 0 | 0 | 0 |
|
||||
| zurhidon-stronghold-124 | all-ros | 840 | 201 | 840 | 0 | 0 | 0 | 0 |
|
||||
|
||||
## Runtime-facing output
|
||||
|
||||
A full build writes `wdb-placement-manifest.json` beside the environment package. Each
|
||||
placement has a stable WDB GUID ID, normalized resource/cache key, source TRS, primary-local
|
||||
Three.js matrix, bounds, flags, asset status, and optional reviewed dynamic definition.
|
||||
Static placements are assembled into the optimized visual and navigation collision. Reviewed
|
||||
dynamic placements are omitted from static collision and packaged as separately addressable GLBs.
|
||||
|
||||
## Unsupported or review-required behavior
|
||||
|
||||
- WDB placement resources other than ROS are recorded but not converted.
|
||||
- Particle, light, sound, camera, and script-only components absent from ROS mesh descriptors remain unsupported.
|
||||
- Animated/stateful behavior remains unsupported until an exact reviewed dynamic-placement rule supplies its kind, initial state, collision policy, and evidence.
|
||||
- A ROS that the preserved exporter cannot decode is skipped, recorded with its resource path and failure, and never replaced with guessed geometry.
|
||||
- The primary-only top-down preview and editable `.blend` do not include the final merged WDB
|
||||
assembly; the generated GLB and placement manifest are authoritative.
|
||||
- Dynamic-candidate filenames are not enough to remove geometry from the static package. Add a
|
||||
reviewed `source.wdbAssembly.dynamicPlacements` rule with evidence first.
|
||||
|
||||
## Validation issues
|
||||
|
||||
- None.
|
||||
@@ -0,0 +1,45 @@
|
||||
# RuneWaker combat formula and loot fidelity
|
||||
|
||||
## Combat damage
|
||||
|
||||
Imported RuneWaker damage mechanics now carry a `runewakerFormula` descriptor from `magicobject.db` into the generated enemy attack. The descriptor keeps the source spell/component ids, spell level, damage power, fixed value, damage-over-time base, calculation family, and source-code provenance.
|
||||
|
||||
The preserved implementation is in `SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp`:
|
||||
|
||||
- `CalPhyDamage` begins at line 3366.
|
||||
- `CalMagicDamage` begins at line 3651.
|
||||
- Both use the source ATK/DEF or MATK/MDEF ratio `max(0.2, 1 + ((attack + 100) - (defense + 200)) / max(attack + 100, defense + 200))`.
|
||||
- Both also depend on native weapon or magic damage, level-difference tables, resistance processing, critical power, fixed-attribute lookup, and global NPC/PvP/AoE/target rates.
|
||||
|
||||
HealerMan does not have equivalent RuneWaker ATK, MATK, DEF, MDEF, `FixType`, native weapon/magic damage, level-rate tables, or global rate arrays. The adapter therefore does not label its live result as exact RuneWaker damage:
|
||||
|
||||
- Negative `Atk.DmgPower` values with magnitude at most 4 are treated as source ratios against HealerMan's level-scaled enemy damage budget; the native 0.2 minimum is retained.
|
||||
- Point-valued source powers use the explicit `runtimeBaselineMultiplier` balancing adapter stored with the formula.
|
||||
- HealerMan's existing hit table, armor, resistance, critical, aura, and absorb pipeline runs afterward and remains identified as HealerMan/WotLK-shaped mitigation.
|
||||
- Unclassified formula records return zero from the adapter rather than executing guessed damage.
|
||||
|
||||
`resolveRunewakerNativeResolvedPower` exposes the source-shaped tail for later use. It requires every native-only input as an argument and supplies no silent one-valued defaults.
|
||||
|
||||
## Loot
|
||||
|
||||
`export-loot-catalog.mjs` reads each source-classified boss's 15 independent `NPCObjectDB.DropItem` slots. It preserves the original rate denominator of 100,000 and recursively expands `TreasureObjectDB`'s weighted, mutually exclusive outcomes and fixed `DropCount` quantities.
|
||||
|
||||
Runtime rules are intentionally bounded:
|
||||
|
||||
- Recognized leaves come only from `itemobject.db`, `armorobject.db`, `weaponobject.db`, `cardobject.db`, `runeobject.db`, `recipeobject.db`, and `keyitemobject.db`.
|
||||
- Treasure recursion stops at 12 levels and cycles fail closed.
|
||||
- Unknown object ids fail closed.
|
||||
- Treasure tables with a Lua predicate fail closed because executing arbitrary preserved server Lua is outside the browser runtime.
|
||||
- `NeedDLv` requirements are preserved and evaluated from the caller-provided source level delta.
|
||||
- Source names are retained, but source stats, quality, and equipment slots are not inferred. Portable drops enter inventory as common, non-equipable `misc` items with zero stats.
|
||||
|
||||
The detailed status for every boss is generated in `RUNEWAKER_LOOT_IMPORT_REPORT.md`. The full probability graph is available in `public/data/runewaker-loot-catalog.json` and the runtime snapshot.
|
||||
|
||||
## Refresh and verification
|
||||
|
||||
```powershell
|
||||
npm run runewaker:spells:refresh
|
||||
npm run runewaker:loot:refresh
|
||||
node --test scripts/runewaker-pipeline/runewaker-loot-import.test.mjs
|
||||
vitest run src/game/runewakerCombatFormula.test.ts src/game/runewakerLootCatalog.test.ts
|
||||
```
|
||||
@@ -63,7 +63,7 @@
|
||||
"movement/leash",
|
||||
"clustering/aggro",
|
||||
"combat/death state",
|
||||
"loot policy",
|
||||
"loot item stats/slots",
|
||||
"objective ordering",
|
||||
"procedural animation"
|
||||
],
|
||||
@@ -74,7 +74,8 @@
|
||||
"patrols",
|
||||
"interactions",
|
||||
"encounter resets",
|
||||
"source loot"
|
||||
"blocked source-loot predicates",
|
||||
"source item stat/slot mapping"
|
||||
],
|
||||
"blockerGroups": {
|
||||
"navigation": {
|
||||
@@ -245,10 +246,20 @@
|
||||
"safety": {
|
||||
"runtimeSqlDependency": false,
|
||||
"unknownEffectsExecute": false,
|
||||
"coefficientsAreSourceExact": false
|
||||
"coefficientsAreSourceExact": false,
|
||||
"unavailableNativeInputsDefaulted": false,
|
||||
"pointValuedDamageUsesLabeledScaledAdapter": true
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"encounterImplementationLedger": {
|
||||
"json": "scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json",
|
||||
"markdown": "scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md",
|
||||
"validationStatus": "green",
|
||||
"completionStatus": "review-required",
|
||||
"deferredSourceRows": 4113,
|
||||
"uniqueMechanicsEvidence": 1524
|
||||
},
|
||||
"resolvedRuntimeObservations": [
|
||||
{
|
||||
"dungeonId": "paspers-shrine",
|
||||
@@ -266,7 +277,7 @@
|
||||
"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.",
|
||||
"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.",
|
||||
"Source formula structure is preserved, but live damage uses a labeled HealerMan baseline adapter because the required native combat-stat inputs are not packaged.",
|
||||
"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."
|
||||
],
|
||||
"dungeons": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# RuneWaker gameplay content audit
|
||||
|
||||
Structural validation: **green**
|
||||
Structural validation: **green**
|
||||
Original-mechanics fidelity: **review-required**
|
||||
|
||||
This offline audit reads packaged recipes, snapshots, generated TypeScript, reports,
|
||||
@@ -16,6 +16,7 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
|
||||
- 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.
|
||||
- The exhaustive encounter implementation ledger tracks 4113 deferred source rows and 1524 unique mechanics evidence records without inflating overlapping regex labels.
|
||||
|
||||
## Authentic source data currently used
|
||||
|
||||
@@ -30,10 +31,12 @@ mechanics evidence, manifests, and creature GLBs. It never connects to SQL Serve
|
||||
- 436 entities use generated Basic Attack/Corrosive Slam.
|
||||
- 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.
|
||||
- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.
|
||||
- Source-backed mapping preserves spell identity, damage family, school, range, cooldown,
|
||||
source power fields, and the exact native formula shape/provenance.
|
||||
- Damage execution uses a labeled HealerMan baseline adapter because native ATK/MATK,
|
||||
DEF/MDEF, weapon power, resist, critical, level-delta, and global rate inputs are unavailable.
|
||||
- Scaling, movement/leash, clustering, aggro, death state, loot item stats/slots, and objective ordering are generic.
|
||||
- Unmapped complex doors, multi-actor phases, patrols, resets, and interactions remain review-required.
|
||||
- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,
|
||||
attack, cast, wound, and death families.
|
||||
|
||||
@@ -142,5 +145,5 @@ Source-mechanics review: 357 templates preserve AutoPlot/Lua evidence; supported
|
||||
- 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.
|
||||
- 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.
|
||||
- Source formula structure is preserved, but live damage uses a labeled HealerMan baseline adapter because the required native combat-stat inputs are not packaged.
|
||||
- 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.
|
||||
@@ -0,0 +1,763 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"status": "portable-with-explicit-fallbacks",
|
||||
"generatedFrom": {
|
||||
"sourceFiles": [
|
||||
{
|
||||
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/npcobject.db",
|
||||
"sha256": "b627b6eb269c30576a6558000a31abe2db472689ef9a60cc1c1e20207abd69f1"
|
||||
},
|
||||
{
|
||||
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/treasureobject.db",
|
||||
"sha256": "6e7433190f4b9f49e3cea32e346128a57c62a5993dd3b98b0de1cd11152895a0"
|
||||
},
|
||||
{
|
||||
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/itemobject.db",
|
||||
"sha256": "00e082b4c18d561d29c893a6400bfe62e3daf90ae539728cb05d1c2893a69880"
|
||||
},
|
||||
{
|
||||
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/armorobject.db",
|
||||
"sha256": "a2f0017c4772165d562ebb08ca0ac51625cdc4453e3670a1501df7d741d2bad3"
|
||||
},
|
||||
{
|
||||
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/weaponobject.db",
|
||||
"sha256": "2bc28eebbe413bd6118f027e3fec1a3953cef371476937d0b65622c542971a67"
|
||||
},
|
||||
{
|
||||
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/cardobject.db",
|
||||
"sha256": "4d154fe733e5359ca9e40c0aefd458ace0cf109b1b01e20da0f6e0683966a59f"
|
||||
},
|
||||
{
|
||||
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/runeobject.db",
|
||||
"sha256": "8ea064e1ae8220a69e3c72bbd5b64dbd149738a2c86f1a5d51201a7f3a799f85"
|
||||
},
|
||||
{
|
||||
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/recipeobject.db",
|
||||
"sha256": "0f7d6ec38f8c52c403065d3f6f5b996117344c194ba42dd22f4d52061a6984e5"
|
||||
},
|
||||
{
|
||||
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/keyitemobject.db",
|
||||
"sha256": "ea06ed6dd9e4111cfd308b3d26d50c9e39a26ad358584db8dee6812e9a91358a"
|
||||
},
|
||||
{
|
||||
"file": "../rom-pvt-server-backups/Runewaker/Resource/data/string_enus.db",
|
||||
"sha256": "4fce021ba9ed110a213e08d3228b2de57cec66ddbbc8cdcf5753204874d9ab75"
|
||||
}
|
||||
],
|
||||
"npcDropAlgorithm": {
|
||||
"file": "SourceCodeVS2019/Server/NetGameServer/MainProc/Global.cpp",
|
||||
"firstRelevantLine": 4707,
|
||||
"rateDenominator": 100000
|
||||
},
|
||||
"treasureAlgorithm": {
|
||||
"file": "SourceCodeVS2019/Libraries/Union/RoleData/RD_ItemProc.cxx",
|
||||
"firstRelevantLine": 3033,
|
||||
"rateDenominator": 100000
|
||||
},
|
||||
"layout": "RuneWaker v0.20 fixed records; read-only export"
|
||||
},
|
||||
"runtimePolicy": {
|
||||
"rollsAreIndependent": true,
|
||||
"treasureOutcomesAreMutuallyExclusive": true,
|
||||
"unknownObjectsExecute": false,
|
||||
"luaPredicatesExecute": false,
|
||||
"sourceItemStatsInvented": false,
|
||||
"importedItemsUseGenericNonEquipableInventoryRepresentation": true
|
||||
},
|
||||
"leafCatalogs": [
|
||||
{
|
||||
"name": "itemobject.db",
|
||||
"count": 13431,
|
||||
"objectType": 2,
|
||||
"recordSize": 1776
|
||||
},
|
||||
{
|
||||
"name": "armorobject.db",
|
||||
"count": 15050,
|
||||
"objectType": 2,
|
||||
"recordSize": 1776
|
||||
},
|
||||
{
|
||||
"name": "weaponobject.db",
|
||||
"count": 4843,
|
||||
"objectType": 2,
|
||||
"recordSize": 1776
|
||||
},
|
||||
{
|
||||
"name": "cardobject.db",
|
||||
"count": 1765,
|
||||
"objectType": 2,
|
||||
"recordSize": 1776
|
||||
},
|
||||
{
|
||||
"name": "runeobject.db",
|
||||
"count": 1781,
|
||||
"objectType": 2,
|
||||
"recordSize": 1776
|
||||
},
|
||||
{
|
||||
"name": "recipeobject.db",
|
||||
"count": 3443,
|
||||
"objectType": 10,
|
||||
"recordSize": 416
|
||||
},
|
||||
{
|
||||
"name": "keyitemobject.db",
|
||||
"count": 10001,
|
||||
"objectType": 9,
|
||||
"recordSize": 196
|
||||
}
|
||||
],
|
||||
"bossCount": 56,
|
||||
"bossWithSourceRollsCount": 47,
|
||||
"portableBossCount": 34,
|
||||
"partialBossCount": 13,
|
||||
"noSourceDropsBossCount": 9,
|
||||
"blockedBossCount": 0,
|
||||
"executableOutcomeCount": 8741,
|
||||
"blockedOutcomeCount": 27,
|
||||
"bosses": [
|
||||
{
|
||||
"bossKey": "rw-barren-caves-101462",
|
||||
"dungeonId": "barren-caves",
|
||||
"sourceNpcId": 101462,
|
||||
"bossName": "Anglo",
|
||||
"status": "partial",
|
||||
"sourceRollCount": 13,
|
||||
"executableOutcomeCount": 76,
|
||||
"blockedOutcomeCount": 3,
|
||||
"blockedReasons": [
|
||||
"Treasure table has no positive, resolvable outcomes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-cave-water-dragon-102623",
|
||||
"dungeonId": "cave-water-dragon",
|
||||
"sourceNpcId": 102623,
|
||||
"bossName": "Lytfir",
|
||||
"status": "no-source-drops",
|
||||
"sourceRollCount": 0,
|
||||
"executableOutcomeCount": 0,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-cave-water-dragon-104161",
|
||||
"dungeonId": "cave-water-dragon",
|
||||
"sourceNpcId": 104161,
|
||||
"bossName": "Template 104161",
|
||||
"status": "no-source-drops",
|
||||
"sourceRollCount": 0,
|
||||
"executableOutcomeCount": 0,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-demon-stronghold-102701",
|
||||
"dungeonId": "demon-stronghold",
|
||||
"sourceNpcId": 102701,
|
||||
"bossName": "Naos",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 228,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-fa-100070",
|
||||
"dungeonId": "forsaken-abbey",
|
||||
"sourceNpcId": 100070,
|
||||
"bossName": "Necromage",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 12,
|
||||
"executableOutcomeCount": 117,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-fa-100074",
|
||||
"dungeonId": "forsaken-abbey",
|
||||
"sourceNpcId": 100074,
|
||||
"bossName": "Demon Witch Ancalon",
|
||||
"status": "partial",
|
||||
"sourceRollCount": 13,
|
||||
"executableOutcomeCount": 130,
|
||||
"blockedOutcomeCount": 2,
|
||||
"blockedReasons": [
|
||||
"Treasure table has no positive, resolvable outcomes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-fa-100561",
|
||||
"dungeonId": "forsaken-abbey",
|
||||
"sourceNpcId": 100561,
|
||||
"bossName": "Flowing Chaos",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 11,
|
||||
"executableOutcomeCount": 127,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-fa-100562",
|
||||
"dungeonId": "forsaken-abbey",
|
||||
"sourceNpcId": 100562,
|
||||
"bossName": "Cruel Eater",
|
||||
"status": "partial",
|
||||
"sourceRollCount": 12,
|
||||
"executableOutcomeCount": 131,
|
||||
"blockedOutcomeCount": 2,
|
||||
"blockedReasons": [
|
||||
"Treasure table has no positive, resolvable outcomes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-fa-100563",
|
||||
"dungeonId": "forsaken-abbey",
|
||||
"sourceNpcId": 100563,
|
||||
"bossName": "Hollow Shell",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 11,
|
||||
"executableOutcomeCount": 129,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-hall-of-survivors-102395",
|
||||
"dungeonId": "hall-of-survivors",
|
||||
"sourceNpcId": 102395,
|
||||
"bossName": "Guldamor",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 228,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-hall-of-survivors-102421",
|
||||
"dungeonId": "hall-of-survivors",
|
||||
"sourceNpcId": 102421,
|
||||
"bossName": "Andriol",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 227,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-hall-of-survivors-102425",
|
||||
"dungeonId": "hall-of-survivors",
|
||||
"sourceNpcId": 102425,
|
||||
"bossName": "Zygro",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 229,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-hall-of-survivors-102430",
|
||||
"dungeonId": "hall-of-survivors",
|
||||
"sourceNpcId": 102430,
|
||||
"bossName": "Andaphelmor",
|
||||
"status": "partial",
|
||||
"sourceRollCount": 14,
|
||||
"executableOutcomeCount": 232,
|
||||
"blockedOutcomeCount": 2,
|
||||
"blockedReasons": [
|
||||
"Treasure table requires unavailable Lua predicate star_WorldBattle_CheckBonus."
|
||||
]
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-hall-of-survivors-102452",
|
||||
"dungeonId": "hall-of-survivors",
|
||||
"sourceNpcId": 102452,
|
||||
"bossName": "Mantarick",
|
||||
"status": "no-source-drops",
|
||||
"sourceRollCount": 0,
|
||||
"executableOutcomeCount": 0,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-hall-of-survivors-102475",
|
||||
"dungeonId": "hall-of-survivors",
|
||||
"sourceNpcId": 102475,
|
||||
"bossName": "Glamo",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 226,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-hall-of-survivors-102476",
|
||||
"dungeonId": "hall-of-survivors",
|
||||
"sourceNpcId": 102476,
|
||||
"bossName": "Divine Punishment Pursuer",
|
||||
"status": "no-source-drops",
|
||||
"sourceRollCount": 0,
|
||||
"executableOutcomeCount": 0,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-hall-of-survivors-102560",
|
||||
"dungeonId": "hall-of-survivors",
|
||||
"sourceNpcId": 102560,
|
||||
"bossName": "Prison Pursuer",
|
||||
"status": "no-source-drops",
|
||||
"sourceRollCount": 0,
|
||||
"executableOutcomeCount": 0,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-hall-of-survivors-102643",
|
||||
"dungeonId": "hall-of-survivors",
|
||||
"sourceNpcId": 102643,
|
||||
"bossName": "Horror Seeker",
|
||||
"status": "no-source-drops",
|
||||
"sourceRollCount": 0,
|
||||
"executableOutcomeCount": 0,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-hoto-102021",
|
||||
"dungeonId": "hoto",
|
||||
"sourceNpcId": 102021,
|
||||
"bossName": "Jiasha",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 219,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-hoto-102040",
|
||||
"dungeonId": "hoto",
|
||||
"sourceNpcId": 102040,
|
||||
"bossName": "Medusa",
|
||||
"status": "partial",
|
||||
"sourceRollCount": 13,
|
||||
"executableOutcomeCount": 222,
|
||||
"blockedOutcomeCount": 2,
|
||||
"blockedReasons": [
|
||||
"Treasure table requires unavailable Lua predicate star_WorldBattle_CheckBonus."
|
||||
]
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-hoto-102041",
|
||||
"dungeonId": "hoto",
|
||||
"sourceNpcId": 102041,
|
||||
"bossName": "Teeth of the Reef",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 217,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-hoto-102046",
|
||||
"dungeonId": "hoto",
|
||||
"sourceNpcId": 102046,
|
||||
"bossName": "Bloodthirsty Claw",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 218,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-hoto-102063",
|
||||
"dungeonId": "hoto",
|
||||
"sourceNpcId": 102063,
|
||||
"bossName": "Geba",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 220,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-ice-dwarf-kingdom-101268",
|
||||
"dungeonId": "ice-dwarf-kingdom",
|
||||
"sourceNpcId": 101268,
|
||||
"bossName": "Pangkor",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 9,
|
||||
"executableOutcomeCount": 210,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-ice-dwarf-kingdom-101584",
|
||||
"dungeonId": "ice-dwarf-kingdom",
|
||||
"sourceNpcId": 101584,
|
||||
"bossName": "Thynos",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 211,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-ice-dwarf-kingdom-101881",
|
||||
"dungeonId": "ice-dwarf-kingdom",
|
||||
"sourceNpcId": 101881,
|
||||
"bossName": "Icebound Guardian",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 188,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-ice-dwarf-kingdom-101883",
|
||||
"dungeonId": "ice-dwarf-kingdom",
|
||||
"sourceNpcId": 101883,
|
||||
"bossName": "Crasset Hairyfoot",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 181,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-ice-dwarf-kingdom-101884",
|
||||
"dungeonId": "ice-dwarf-kingdom",
|
||||
"sourceNpcId": 101884,
|
||||
"bossName": "Mole King",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 8,
|
||||
"executableOutcomeCount": 208,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-kalins-shrine-101500",
|
||||
"dungeonId": "kalins-shrine",
|
||||
"sourceNpcId": 101500,
|
||||
"bossName": "Goddess of Art's Disciple",
|
||||
"status": "partial",
|
||||
"sourceRollCount": 13,
|
||||
"executableOutcomeCount": 318,
|
||||
"blockedOutcomeCount": 2,
|
||||
"blockedReasons": [
|
||||
"Treasure table has no positive, resolvable outcomes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-kalins-shrine-101501",
|
||||
"dungeonId": "kalins-shrine",
|
||||
"sourceNpcId": 101501,
|
||||
"bossName": "Yusalien",
|
||||
"status": "partial",
|
||||
"sourceRollCount": 14,
|
||||
"executableOutcomeCount": 326,
|
||||
"blockedOutcomeCount": 2,
|
||||
"blockedReasons": [
|
||||
"Treasure table has no positive, resolvable outcomes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-kalins-shrine-101502",
|
||||
"dungeonId": "kalins-shrine",
|
||||
"sourceNpcId": 101502,
|
||||
"bossName": "Locatha",
|
||||
"status": "partial",
|
||||
"sourceRollCount": 11,
|
||||
"executableOutcomeCount": 325,
|
||||
"blockedOutcomeCount": 2,
|
||||
"blockedReasons": [
|
||||
"Treasure table has no positive, resolvable outcomes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-kalins-shrine-101503",
|
||||
"dungeonId": "kalins-shrine",
|
||||
"sourceNpcId": 101503,
|
||||
"bossName": "Ensia",
|
||||
"status": "partial",
|
||||
"sourceRollCount": 11,
|
||||
"executableOutcomeCount": 325,
|
||||
"blockedOutcomeCount": 2,
|
||||
"blockedReasons": [
|
||||
"Treasure table has no positive, resolvable outcomes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-kalins-shrine-101507",
|
||||
"dungeonId": "kalins-shrine",
|
||||
"sourceNpcId": 101507,
|
||||
"bossName": "Iron Rune Warrior",
|
||||
"status": "partial",
|
||||
"sourceRollCount": 11,
|
||||
"executableOutcomeCount": 323,
|
||||
"blockedOutcomeCount": 2,
|
||||
"blockedReasons": [
|
||||
"Treasure table has no positive, resolvable outcomes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-kawaks-tomb-104228",
|
||||
"dungeonId": "kawaks-tomb",
|
||||
"sourceNpcId": 104228,
|
||||
"bossName": "Linobia",
|
||||
"status": "no-source-drops",
|
||||
"sourceRollCount": 0,
|
||||
"executableOutcomeCount": 0,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-kulech-bones-nest-107306",
|
||||
"dungeonId": "kulech-bones-nest",
|
||||
"sourceNpcId": 107306,
|
||||
"bossName": "Template 107306",
|
||||
"status": "no-source-drops",
|
||||
"sourceRollCount": 0,
|
||||
"executableOutcomeCount": 0,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-mystic-altar-100138",
|
||||
"dungeonId": "mystic-altar",
|
||||
"sourceNpcId": 100138,
|
||||
"bossName": "Cursed lron Golem",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 128,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-mystic-altar-100154",
|
||||
"dungeonId": "mystic-altar",
|
||||
"sourceNpcId": 100154,
|
||||
"bossName": "Razeela",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 142,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-mystic-altar-100611",
|
||||
"dungeonId": "mystic-altar",
|
||||
"sourceNpcId": 100611,
|
||||
"bossName": "Dorlos",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 11,
|
||||
"executableOutcomeCount": 124,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-paspers-shrine-100794",
|
||||
"dungeonId": "paspers-shrine",
|
||||
"sourceNpcId": 100794,
|
||||
"bossName": "Blackhorn Hafiz",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 11,
|
||||
"executableOutcomeCount": 128,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-paspers-shrine-101018",
|
||||
"dungeonId": "paspers-shrine",
|
||||
"sourceNpcId": 101018,
|
||||
"bossName": "Blackhorn Afalen",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 109,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-paspers-shrine-101019",
|
||||
"dungeonId": "paspers-shrine",
|
||||
"sourceNpcId": 101019,
|
||||
"bossName": "Blackhorn Bareth",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 103,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-paspers-shrine-101020",
|
||||
"dungeonId": "paspers-shrine",
|
||||
"sourceNpcId": 101020,
|
||||
"bossName": "Blackhorn Nisorn",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 105,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-paspers-shrine-101021",
|
||||
"dungeonId": "paspers-shrine",
|
||||
"sourceNpcId": 101021,
|
||||
"bossName": "Blackhorn Dorglas",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 13,
|
||||
"executableOutcomeCount": 112,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-raksha-temple-103946",
|
||||
"dungeonId": "raksha-temple",
|
||||
"sourceNpcId": 103946,
|
||||
"bossName": "Shadoj",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 7,
|
||||
"executableOutcomeCount": 71,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-sand-vortex-100645",
|
||||
"dungeonId": "sand-vortex",
|
||||
"sourceNpcId": 100645,
|
||||
"bossName": "Sandlord Sesphiroth",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 9,
|
||||
"executableOutcomeCount": 101,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-sardo-castle-105701",
|
||||
"dungeonId": "sardo-castle",
|
||||
"sourceNpcId": 105701,
|
||||
"bossName": "Undead Guard",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 7,
|
||||
"executableOutcomeCount": 91,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-the-origin-102307",
|
||||
"dungeonId": "the-origin",
|
||||
"sourceNpcId": 102307,
|
||||
"bossName": "Holy Tree Falynum",
|
||||
"status": "no-source-drops",
|
||||
"sourceRollCount": 0,
|
||||
"executableOutcomeCount": 0,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-the-origin-102349",
|
||||
"dungeonId": "the-origin",
|
||||
"sourceNpcId": 102349,
|
||||
"bossName": "Life Leecher",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 12,
|
||||
"executableOutcomeCount": 279,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-treasure-trove-101533",
|
||||
"dungeonId": "treasure-trove",
|
||||
"sourceNpcId": 101533,
|
||||
"bossName": "Boulder Sidaar",
|
||||
"status": "partial",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 313,
|
||||
"blockedOutcomeCount": 2,
|
||||
"blockedReasons": [
|
||||
"Treasure table has no positive, resolvable outcomes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-treasure-trove-101534",
|
||||
"dungeonId": "treasure-trove",
|
||||
"sourceNpcId": 101534,
|
||||
"bossName": "Lookout Luke",
|
||||
"status": "partial",
|
||||
"sourceRollCount": 11,
|
||||
"executableOutcomeCount": 315,
|
||||
"blockedOutcomeCount": 2,
|
||||
"blockedReasons": [
|
||||
"Treasure table has no positive, resolvable outcomes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-treasure-trove-101535",
|
||||
"dungeonId": "treasure-trove",
|
||||
"sourceNpcId": 101535,
|
||||
"bossName": "Snow Blake",
|
||||
"status": "partial",
|
||||
"sourceRollCount": 13,
|
||||
"executableOutcomeCount": 322,
|
||||
"blockedOutcomeCount": 2,
|
||||
"blockedReasons": [
|
||||
"Treasure table has no positive, resolvable outcomes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-ystra-labyrinth-boss1-101432",
|
||||
"dungeonId": "ystra-labyrinth-boss1",
|
||||
"sourceNpcId": 101432,
|
||||
"bossName": "Rune Guardian Leader",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 12,
|
||||
"executableOutcomeCount": 92,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-ystra-labyrinth-boss1-101433",
|
||||
"dungeonId": "ystra-labyrinth-boss1",
|
||||
"sourceNpcId": 101433,
|
||||
"bossName": "Raichika the Destroyer",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 13,
|
||||
"executableOutcomeCount": 125,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-ystra-labyrinth-boss1-102195",
|
||||
"dungeonId": "ystra-labyrinth-boss1",
|
||||
"sourceNpcId": 102195,
|
||||
"bossName": "Specter Guardian Creator",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 8,
|
||||
"executableOutcomeCount": 84,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-ystra-labyrinth-boss2-102196",
|
||||
"dungeonId": "ystra-labyrinth-boss2",
|
||||
"sourceNpcId": 102196,
|
||||
"bossName": "Rune Guardian Leader",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 99,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
},
|
||||
{
|
||||
"bossKey": "rw-ystra-labyrinth-boss3-102197",
|
||||
"dungeonId": "ystra-labyrinth-boss3",
|
||||
"sourceNpcId": 102197,
|
||||
"bossName": "Raichika the Destroyer",
|
||||
"status": "portable",
|
||||
"sourceRollCount": 10,
|
||||
"executableOutcomeCount": 107,
|
||||
"blockedOutcomeCount": 0,
|
||||
"blockedReasons": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
# RuneWaker loot import report
|
||||
|
||||
Generated from the preserved fixed-record databases. Source rates use the original 1-in-100,000 denominator.
|
||||
|
||||
- Boss templates audited: 56
|
||||
- Bosses with source rolls: 47
|
||||
- Fully portable bosses: 34
|
||||
- Partially portable bosses: 13
|
||||
- Bosses with no authored drop slots: 9
|
||||
- Executable item outcomes: 8741
|
||||
- Blocked outcomes: 27
|
||||
|
||||
Unknown object relationships and Lua-gated treasure paths fail closed. Imported source items are collected as named, non-equipable generic items because the RuneWaker item-stat and equipment-slot conversion is not yet proven.
|
||||
|
||||
| Dungeon | Boss | Status | Rolls | Executable | Blocked | Blocker |
|
||||
| --- | --- | --- | ---: | ---: | ---: | --- |
|
||||
| barren-caves | Anglo | partial | 13 | 76 | 3 | Treasure table has no positive, resolvable outcomes. |
|
||||
| cave-water-dragon | Lytfir | no-source-drops | 0 | 0 | 0 | — |
|
||||
| cave-water-dragon | Template 104161 | no-source-drops | 0 | 0 | 0 | — |
|
||||
| demon-stronghold | Naos | portable | 10 | 228 | 0 | — |
|
||||
| forsaken-abbey | Necromage | portable | 12 | 117 | 0 | — |
|
||||
| forsaken-abbey | Demon Witch Ancalon | partial | 13 | 130 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||
| forsaken-abbey | Flowing Chaos | portable | 11 | 127 | 0 | — |
|
||||
| forsaken-abbey | Cruel Eater | partial | 12 | 131 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||
| forsaken-abbey | Hollow Shell | portable | 11 | 129 | 0 | — |
|
||||
| hall-of-survivors | Guldamor | portable | 10 | 228 | 0 | — |
|
||||
| hall-of-survivors | Andriol | portable | 10 | 227 | 0 | — |
|
||||
| hall-of-survivors | Zygro | portable | 10 | 229 | 0 | — |
|
||||
| hall-of-survivors | Andaphelmor | partial | 14 | 232 | 2 | Treasure table requires unavailable Lua predicate star_WorldBattle_CheckBonus. |
|
||||
| hall-of-survivors | Mantarick | no-source-drops | 0 | 0 | 0 | — |
|
||||
| hall-of-survivors | Glamo | portable | 10 | 226 | 0 | — |
|
||||
| hall-of-survivors | Divine Punishment Pursuer | no-source-drops | 0 | 0 | 0 | — |
|
||||
| hall-of-survivors | Prison Pursuer | no-source-drops | 0 | 0 | 0 | — |
|
||||
| hall-of-survivors | Horror Seeker | no-source-drops | 0 | 0 | 0 | — |
|
||||
| hoto | Jiasha | portable | 10 | 219 | 0 | — |
|
||||
| hoto | Medusa | partial | 13 | 222 | 2 | Treasure table requires unavailable Lua predicate star_WorldBattle_CheckBonus. |
|
||||
| hoto | Teeth of the Reef | portable | 10 | 217 | 0 | — |
|
||||
| hoto | Bloodthirsty Claw | portable | 10 | 218 | 0 | — |
|
||||
| hoto | Geba | portable | 10 | 220 | 0 | — |
|
||||
| ice-dwarf-kingdom | Pangkor | portable | 9 | 210 | 0 | — |
|
||||
| ice-dwarf-kingdom | Thynos | portable | 10 | 211 | 0 | — |
|
||||
| ice-dwarf-kingdom | Icebound Guardian | portable | 10 | 188 | 0 | — |
|
||||
| ice-dwarf-kingdom | Crasset Hairyfoot | portable | 10 | 181 | 0 | — |
|
||||
| ice-dwarf-kingdom | Mole King | portable | 8 | 208 | 0 | — |
|
||||
| kalins-shrine | Goddess of Art's Disciple | partial | 13 | 318 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||
| kalins-shrine | Yusalien | partial | 14 | 326 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||
| kalins-shrine | Locatha | partial | 11 | 325 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||
| kalins-shrine | Ensia | partial | 11 | 325 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||
| kalins-shrine | Iron Rune Warrior | partial | 11 | 323 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||
| kawaks-tomb | Linobia | no-source-drops | 0 | 0 | 0 | — |
|
||||
| kulech-bones-nest | Template 107306 | no-source-drops | 0 | 0 | 0 | — |
|
||||
| mystic-altar | Cursed lron Golem | portable | 10 | 128 | 0 | — |
|
||||
| mystic-altar | Razeela | portable | 10 | 142 | 0 | — |
|
||||
| mystic-altar | Dorlos | portable | 11 | 124 | 0 | — |
|
||||
| paspers-shrine | Blackhorn Hafiz | portable | 11 | 128 | 0 | — |
|
||||
| paspers-shrine | Blackhorn Afalen | portable | 10 | 109 | 0 | — |
|
||||
| paspers-shrine | Blackhorn Bareth | portable | 10 | 103 | 0 | — |
|
||||
| paspers-shrine | Blackhorn Nisorn | portable | 10 | 105 | 0 | — |
|
||||
| paspers-shrine | Blackhorn Dorglas | portable | 13 | 112 | 0 | — |
|
||||
| raksha-temple | Shadoj | portable | 7 | 71 | 0 | — |
|
||||
| sand-vortex | Sandlord Sesphiroth | portable | 9 | 101 | 0 | — |
|
||||
| sardo-castle | Undead Guard | portable | 7 | 91 | 0 | — |
|
||||
| the-origin | Holy Tree Falynum | no-source-drops | 0 | 0 | 0 | — |
|
||||
| the-origin | Life Leecher | portable | 12 | 279 | 0 | — |
|
||||
| treasure-trove | Boulder Sidaar | partial | 10 | 313 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||
| treasure-trove | Lookout Luke | partial | 11 | 315 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||
| treasure-trove | Snow Blake | partial | 13 | 322 | 2 | Treasure table has no positive, resolvable outcomes. |
|
||||
| ystra-labyrinth-boss1 | Rune Guardian Leader | portable | 12 | 92 | 0 | — |
|
||||
| ystra-labyrinth-boss1 | Raichika the Destroyer | portable | 13 | 125 | 0 | — |
|
||||
| ystra-labyrinth-boss1 | Specter Guardian Creator | portable | 8 | 84 | 0 | — |
|
||||
| ystra-labyrinth-boss2 | Rune Guardian Leader | portable | 10 | 99 | 0 | — |
|
||||
| ystra-labyrinth-boss3 | Raichika the Destroyer | portable | 10 | 107 | 0 | — |
|
||||
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,9 @@ against every raw Recast surface component and against the retained runtime surf
|
||||
The current boss sequence is HealerMan's distance-from-entrance fallback, not a claimed
|
||||
source-authored order. It does not create links or infer doors/teleports.
|
||||
|
||||
Unreachable heuristic legs analyzed: **19** across **8** dungeons.
|
||||
Unreachable currently ordered legs analyzed: **19** across **8** dungeons.
|
||||
Source-authored fixed orders implemented by the audited packages: **0**.
|
||||
Authored off-mesh links added by this review: **0**.
|
||||
|
||||
| Dungeon | Ordered leg | Geometry classification | From raw comp/dist | To raw comp/dist | Retained distances |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
@@ -20,9 +22,9 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
|
||||
| kalins-shrine | Yusalien -> Locatha | separate-recast-surface-components | 2/0m | 12/0m | 245.55m -> 122.731m |
|
||||
| kalins-shrine | Locatha -> Goddess of Art's Disciple | separate-recast-surface-components | 12/0m | 3/0m | 122.731m -> 12.741m |
|
||||
| kalins-shrine | Goddess of Art's Disciple -> Ensia | separate-recast-surface-components | 3/0m | 0/0m | 12.741m -> 0m |
|
||||
| raksha-temple | entrance -> Shadoj | missing-primary-environment-geometry-source-support-pad | 41/0m | 165/0m | 0.203m -> 200.079m |
|
||||
| the-origin | entrance -> Life Leecher | missing-primary-environment-geometry-source-support-pad | 106/0m | 302/0m | 466.405m -> 234.143m |
|
||||
| the-origin | Life Leecher -> Holy Tree Falynum | missing-primary-environment-geometry-source-support-pad | 302/0m | 305/0m | 234.143m -> 361.263m |
|
||||
| raksha-temple | entrance -> Shadoj | objective-or-entrance-outside-all-navigation | 6069/0m | 2982/7.159m | 578.214m -> 699.329m |
|
||||
| the-origin | entrance -> Life Leecher | separate-recast-surface-components | 4276/0m | 2868/1.099m | 456.333m -> 258.631m |
|
||||
| the-origin | Life Leecher -> Holy Tree Falynum | separate-recast-surface-components | 2868/1.099m | 4857/5.632m | 258.631m -> 559.406m |
|
||||
| treasure-trove | entrance -> Lookout Luke | missing-primary-environment-geometry-source-support-pad | 487/0m | 854/0m | 0.184m -> 519.5m |
|
||||
| treasure-trove | Lookout Luke -> Snow Blake | missing-primary-environment-geometry-source-support-pad | 854/0m | 490/0m | 519.5m -> 525.287m |
|
||||
| treasure-trove | Snow Blake -> Boulder Sidaar | missing-primary-environment-geometry-source-support-pad | 490/0m | 489/0m | 525.287m -> 565.311m |
|
||||
@@ -35,8 +37,8 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
|
||||
- hoto: 260 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 5777 welded nodes discarded by largest-surface retention.
|
||||
- ice-dwarf-kingdom: 599 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 6237 welded nodes discarded by largest-surface retention.
|
||||
- kalins-shrine: 93 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 4858 welded nodes discarded by largest-surface retention.
|
||||
- raksha-temple: 167 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 1262 welded nodes discarded by largest-surface retention.
|
||||
- the-origin: 306 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 1678 welded nodes discarded by largest-surface retention.
|
||||
- raksha-temple: 13137 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 180838 welded nodes discarded by largest-surface retention.
|
||||
- the-origin: 6736 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 97103 welded nodes discarded by largest-surface retention.
|
||||
- treasure-trove: 1025 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 16502 welded nodes discarded by largest-surface retention.
|
||||
- ystra-labyrinth-boss1: 36 raw vertex-connected surfaces; 1 shared-edge corridors inside the retained surface; 901 welded nodes discarded by largest-surface retention.
|
||||
|
||||
@@ -46,7 +48,12 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
|
||||
|
||||
- Disposition: gated-wave-and-door-review.
|
||||
- Safe automatic link: no.
|
||||
- Objective order: partial-gate-evidence-insufficient-for-complete-order.
|
||||
- Current package order: healerMan-distance-derived (non-authoritative).
|
||||
- Finding: Template 104161 runs the ten-add water-elemental controller, creates a blocking door, and removes it only after the counter reaches ten. Lytfir is on a lower isolated surface, so a permanent link would bypass the preserved gate/drop sequence.
|
||||
- Remaining requirement: encounter-gate-state.
|
||||
- Remaining requirement: source-objective-review.
|
||||
- Remaining requirement: dynamic-navigation-transition.
|
||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:1
|
||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:21
|
||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:42
|
||||
@@ -55,7 +62,11 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
|
||||
|
||||
- Disposition: source-order-required-before-linking.
|
||||
- Safe automatic link: no.
|
||||
- Objective order: source-objective-review-required.
|
||||
- Current package order: healerMan-distance-derived (non-authoritative).
|
||||
- Finding: The distance fallback crosses the same 6.65m vertical surface break three times. Preserved local configuration supplies boss anchors but no authoritative encounter order, so these heuristic legs cannot justify a link.
|
||||
- Remaining requirement: source-objective-review.
|
||||
- Remaining requirement: navigation-geometry-review.
|
||||
- Evidence: scripts/runewaker-pipeline/build-population.mjs:634
|
||||
- Evidence: scripts/runewaker-pipeline/recipes/hoto-population.json
|
||||
|
||||
@@ -63,7 +74,12 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
|
||||
|
||||
- Disposition: door-gated-room-review.
|
||||
- Safe automatic link: no.
|
||||
- Objective order: partial-gate-evidence-insufficient-for-complete-order.
|
||||
- Current package order: healerMan-distance-derived (non-authoritative).
|
||||
- Finding: Crasset's isolated surface is separated by a 1.64m/0.60m gap, but his preserved death hook creates an orb that finds and hides door 112321. A permanent bidirectional link would erase that gate state.
|
||||
- Remaining requirement: encounter-gate-state.
|
||||
- Remaining requirement: source-objective-review.
|
||||
- Remaining requirement: dynamic-navigation-transition.
|
||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:16
|
||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:36
|
||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:46
|
||||
@@ -73,30 +89,48 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
|
||||
|
||||
- Disposition: teleport-room-mechanic.
|
||||
- Safe automatic link: no.
|
||||
- Finding: The preserved server scripts register six Kalin locations as separate keeper rooms and advance with a group teleport. The vertical component gaps are therefore encounter state, not wall seams.
|
||||
- Objective order: source-randomized-not-linear.
|
||||
- Current package order: healerMan-distance-derived (non-authoritative).
|
||||
- Finding: The preserved server scripts register six Kalin locations as separate keeper rooms, select the next uncleared room randomly, and advance with a group teleport. The vertical component gaps are encounter state, not wall seams, and a fixed objective order would contradict the source.
|
||||
- Remaining requirement: nonlinear-objectives.
|
||||
- Remaining requirement: encounter-teleport-state.
|
||||
- Remaining requirement: dynamic-navigation-transition.
|
||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:713
|
||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1298
|
||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1363
|
||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1354
|
||||
|
||||
### raksha-temple
|
||||
|
||||
- Disposition: missing-modular-environment-assembly.
|
||||
- Safe automatic link: no.
|
||||
- Objective order: not-applicable-single-objective.
|
||||
- Current package order: healerMan-distance-derived (non-authoritative).
|
||||
- Finding: The environment recipe explicitly adds isolated source-anchor support pads because the single primary ROS does not cover the modular WDB combat anchors. Secondary environment pieces must be assembled before route authoring.
|
||||
- Remaining requirement: modular-environment-assembly.
|
||||
- Evidence: scripts/runewaker-pipeline/recipes/raksha-temple.json:16
|
||||
|
||||
### the-origin
|
||||
|
||||
- Disposition: missing-modular-environment-assembly.
|
||||
- Safe automatic link: no.
|
||||
- Objective order: source-objective-review-required.
|
||||
- Current package order: healerMan-distance-derived (non-authoritative).
|
||||
- Finding: Both objectives and the entrance sit on isolated source-anchor support pads. The WDB contains many separately placed cathedral, slope, bridge, and connector ROS pieces that the primary-only environment package does not yet assemble.
|
||||
- Remaining requirement: modular-environment-assembly.
|
||||
- Remaining requirement: source-objective-review.
|
||||
- Evidence: scripts/runewaker-pipeline/recipes/the-origin.json:16
|
||||
|
||||
### treasure-trove
|
||||
|
||||
- Disposition: missing-modular-environment-plus-bridge-door-mechanics.
|
||||
- Safe automatic link: no.
|
||||
- Objective order: partial-gate-evidence-insufficient-for-complete-order.
|
||||
- Current package order: healerMan-distance-derived (non-authoritative).
|
||||
- Finding: Boss anchors are isolated support pads, while preserved Lua requires a boss-enabled drawbridge switch and a key-gated hold door. The modular environment and stateful bridge/door must precede navigation links.
|
||||
- Remaining requirement: modular-environment-assembly.
|
||||
- Remaining requirement: encounter-gate-state.
|
||||
- Remaining requirement: source-objective-review.
|
||||
- Remaining requirement: dynamic-navigation-transition.
|
||||
- Evidence: scripts/runewaker-pipeline/recipes/treasure-trove.json:16
|
||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01159.lua:1
|
||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/01159.lua:38
|
||||
@@ -106,9 +140,29 @@ Unreachable heuristic legs analyzed: **19** across **8** dungeons.
|
||||
|
||||
- Disposition: separate-authored-room-placements.
|
||||
- Safe automatic link: no.
|
||||
- Objective order: independent-room-order-unresolved.
|
||||
- Current package order: healerMan-distance-derived (non-authoritative).
|
||||
- Finding: The package intentionally clones three distinct WDB room placements hundreds of meters apart; the preserved travel catalog also treats the boss-room destinations separately. Connecting them through open space would invent geometry.
|
||||
- Remaining requirement: encounter-teleport-state.
|
||||
- Remaining requirement: source-objective-review.
|
||||
- Remaining requirement: dynamic-navigation-transition.
|
||||
- Evidence: scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1.json:16
|
||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1423
|
||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1425
|
||||
- Evidence: ../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1426
|
||||
|
||||
## Resolution boundary
|
||||
|
||||
No off-mesh link was emitted. Every failed leg is either controlled by encounter state,
|
||||
depends on environment geometry that is not yet in the runtime package, represents
|
||||
separate authored rooms, or still lacks a complete source-authored boss sequence.
|
||||
A static link would let companions bypass a door, bridge, wave, or teleport mechanic.
|
||||
The structured requirement counts below are deterministic and identify the remaining work.
|
||||
|
||||
- dynamic-navigation-transition: 5 dungeon(s).
|
||||
- encounter-gate-state: 3 dungeon(s).
|
||||
- encounter-teleport-state: 2 dungeon(s).
|
||||
- modular-environment-assembly: 3 dungeon(s).
|
||||
- navigation-geometry-review: 1 dungeon(s).
|
||||
- nonlinear-objectives: 1 dungeon(s).
|
||||
- source-objective-review: 6 dungeon(s).
|
||||
@@ -29,6 +29,8 @@ const reviewedFindings = {
|
||||
"cave-water-dragon": {
|
||||
disposition: "gated-wave-and-door-review",
|
||||
safeAutomaticLink: false,
|
||||
objectiveOrderStatus: "partial-gate-evidence-insufficient-for-complete-order",
|
||||
requirements: ["encounter-gate-state", "source-objective-review", "dynamic-navigation-transition"],
|
||||
finding: "Template 104161 runs the ten-add water-elemental controller, creates a blocking door, and removes it only after the counter reaches ten. Lytfir is on a lower isolated surface, so a permanent link would bypass the preserved gate/drop sequence.",
|
||||
evidence: [
|
||||
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01562.lua:1",
|
||||
@@ -39,6 +41,8 @@ const reviewedFindings = {
|
||||
hoto: {
|
||||
disposition: "source-order-required-before-linking",
|
||||
safeAutomaticLink: false,
|
||||
objectiveOrderStatus: "source-objective-review-required",
|
||||
requirements: ["source-objective-review", "navigation-geometry-review"],
|
||||
finding: "The distance fallback crosses the same 6.65m vertical surface break three times. Preserved local configuration supplies boss anchors but no authoritative encounter order, so these heuristic legs cannot justify a link.",
|
||||
evidence: [
|
||||
"scripts/runewaker-pipeline/build-population.mjs:634",
|
||||
@@ -48,6 +52,8 @@ const reviewedFindings = {
|
||||
"ice-dwarf-kingdom": {
|
||||
disposition: "door-gated-room-review",
|
||||
safeAutomaticLink: false,
|
||||
objectiveOrderStatus: "partial-gate-evidence-insufficient-for-complete-order",
|
||||
requirements: ["encounter-gate-state", "source-objective-review", "dynamic-navigation-transition"],
|
||||
finding: "Crasset's isolated surface is separated by a 1.64m/0.60m gap, but his preserved death hook creates an orb that finds and hides door 112321. A permanent bidirectional link would erase that gate state.",
|
||||
evidence: [
|
||||
"../rom-pvt-server-backups/Runewaker/Resource/luascript/01356.lua:16",
|
||||
@@ -59,28 +65,41 @@ const reviewedFindings = {
|
||||
"kalins-shrine": {
|
||||
disposition: "teleport-room-mechanic",
|
||||
safeAutomaticLink: false,
|
||||
finding: "The preserved server scripts register six Kalin locations as separate keeper rooms and advance with a group teleport. The vertical component gaps are therefore encounter state, not wall seams.",
|
||||
objectiveOrderStatus: "source-randomized-not-linear",
|
||||
requirements: ["nonlinear-objectives", "encounter-teleport-state", "dynamic-navigation-transition"],
|
||||
finding: "The preserved server scripts register six Kalin locations as separate keeper rooms, select the next uncleared room randomly, and advance with a group teleport. The vertical component gaps are encounter state, not wall seams, and a fixed objective order would contradict the source.",
|
||||
evidence: [
|
||||
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:713",
|
||||
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1298",
|
||||
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1363",
|
||||
"../rom-pvt-server-backups/Runewaker/Resource/luascript/03246.lua:1354",
|
||||
],
|
||||
},
|
||||
"raksha-temple": {
|
||||
disposition: "missing-modular-environment-assembly",
|
||||
safeAutomaticLink: false,
|
||||
objectiveOrderStatus: "not-applicable-single-objective",
|
||||
requirements: ["modular-environment-assembly"],
|
||||
finding: "The environment recipe explicitly adds isolated source-anchor support pads because the single primary ROS does not cover the modular WDB combat anchors. Secondary environment pieces must be assembled before route authoring.",
|
||||
evidence: ["scripts/runewaker-pipeline/recipes/raksha-temple.json:16"],
|
||||
},
|
||||
"the-origin": {
|
||||
disposition: "missing-modular-environment-assembly",
|
||||
safeAutomaticLink: false,
|
||||
objectiveOrderStatus: "source-objective-review-required",
|
||||
requirements: ["modular-environment-assembly", "source-objective-review"],
|
||||
finding: "Both objectives and the entrance sit on isolated source-anchor support pads. The WDB contains many separately placed cathedral, slope, bridge, and connector ROS pieces that the primary-only environment package does not yet assemble.",
|
||||
evidence: ["scripts/runewaker-pipeline/recipes/the-origin.json:16"],
|
||||
},
|
||||
"treasure-trove": {
|
||||
disposition: "missing-modular-environment-plus-bridge-door-mechanics",
|
||||
safeAutomaticLink: false,
|
||||
objectiveOrderStatus: "partial-gate-evidence-insufficient-for-complete-order",
|
||||
requirements: [
|
||||
"modular-environment-assembly",
|
||||
"encounter-gate-state",
|
||||
"source-objective-review",
|
||||
"dynamic-navigation-transition",
|
||||
],
|
||||
finding: "Boss anchors are isolated support pads, while preserved Lua requires a boss-enabled drawbridge switch and a key-gated hold door. The modular environment and stateful bridge/door must precede navigation links.",
|
||||
evidence: [
|
||||
"scripts/runewaker-pipeline/recipes/treasure-trove.json:16",
|
||||
@@ -92,6 +111,8 @@ const reviewedFindings = {
|
||||
"ystra-labyrinth-boss1": {
|
||||
disposition: "separate-authored-room-placements",
|
||||
safeAutomaticLink: false,
|
||||
objectiveOrderStatus: "independent-room-order-unresolved",
|
||||
requirements: ["encounter-teleport-state", "source-objective-review", "dynamic-navigation-transition"],
|
||||
finding: "The package intentionally clones three distinct WDB room placements hundreds of meters apart; the preserved travel catalog also treats the boss-room destinations separately. Connecting them through open space would invent geometry.",
|
||||
evidence: [
|
||||
"scripts/runewaker-pipeline/recipes/ystra-labyrinth-boss1.json:16",
|
||||
@@ -418,7 +439,9 @@ function renderMarkdown(report) {
|
||||
"The current boss sequence is HealerMan's distance-from-entrance fallback, not a claimed",
|
||||
"source-authored order. It does not create links or infer doors/teleports.",
|
||||
"",
|
||||
`Unreachable heuristic legs analyzed: **${report.unreachableLegs}** across **${report.dungeons.length}** dungeons.`,
|
||||
`Unreachable currently ordered legs analyzed: **${report.unreachableLegs}** across **${report.dungeons.length}** dungeons.`,
|
||||
`Source-authored fixed orders implemented by the audited packages: **${report.resolutionSummary.sourceAuthoredFixedOrders}**.`,
|
||||
`Authored off-mesh links added by this review: **${report.resolutionSummary.authoredOffMeshLinksAdded}**.`,
|
||||
"",
|
||||
"| Dungeon | Ordered leg | Geometry classification | From raw comp/dist | To raw comp/dist | Retained distances |",
|
||||
"| --- | --- | --- | --- | --- | --- |",
|
||||
@@ -442,9 +465,21 @@ function renderMarkdown(report) {
|
||||
const finding = dungeon.reviewedFinding;
|
||||
lines.push(`### ${dungeon.id}`, "", `- Disposition: ${finding.disposition}.`,
|
||||
`- Safe automatic link: ${finding.safeAutomaticLink ? "yes" : "no"}.`,
|
||||
`- Objective order: ${finding.objectiveOrderStatus}.`,
|
||||
`- Current package order: ${dungeon.objectiveOrdering.kind} (${dungeon.objectiveOrdering.authoritative ? "authoritative" : "non-authoritative"}).`,
|
||||
`- Finding: ${finding.finding}`,
|
||||
...finding.requirements.map((entry) => `- Remaining requirement: ${entry}.`),
|
||||
...finding.evidence.map((entry) => `- Evidence: ${entry}`), "");
|
||||
}
|
||||
lines.push("## Resolution boundary", "",
|
||||
"No off-mesh link was emitted. Every failed leg is either controlled by encounter state,",
|
||||
"depends on environment geometry that is not yet in the runtime package, represents",
|
||||
"separate authored rooms, or still lacks a complete source-authored boss sequence.",
|
||||
"A static link would let companions bypass a door, bridge, wave, or teleport mechanic.",
|
||||
"The structured requirement counts below are deterministic and identify the remaining work.", "");
|
||||
for (const [requirement, count] of Object.entries(report.resolutionSummary.requirementDungeonCounts)) {
|
||||
lines.push(`- ${requirement}: ${count} dungeon(s).`);
|
||||
}
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -474,6 +509,11 @@ for (const recipeName of recipeNames) {
|
||||
constantCase(recipe.dungeonId) + "_BOSSES",
|
||||
" as const satisfies readonly DungeonBossObjective[];",
|
||||
);
|
||||
const populationProvenance = generatedJson(
|
||||
source,
|
||||
constantCase(recipe.dungeonId) + "_POPULATION_PROVENANCE",
|
||||
" as const;",
|
||||
);
|
||||
const graph = buildPartyNavigationGraph(geometryFromPositions(positions));
|
||||
const retainedTriangleComponents = triangleComponents(graph);
|
||||
const raw = analyzeRawComponents(positions);
|
||||
@@ -519,9 +559,12 @@ for (const recipeName of recipeNames) {
|
||||
reviewedFinding: reviewedFindings[recipe.dungeonId] ?? {
|
||||
disposition: "manual-review-required",
|
||||
safeAutomaticLink: false,
|
||||
objectiveOrderStatus: "source-objective-review-required",
|
||||
requirements: ["source-objective-review"],
|
||||
finding: "No reviewed source-backed navigation disposition is recorded.",
|
||||
evidence: [],
|
||||
},
|
||||
objectiveOrdering: populationProvenance.objectiveOrder,
|
||||
rawComponents: raw.components.map((component) => ({
|
||||
id: component.id,
|
||||
vertexCount: component.vertices.length,
|
||||
@@ -532,15 +575,26 @@ for (const recipeName of recipeNames) {
|
||||
});
|
||||
}
|
||||
|
||||
const requirementDungeonCounts = {};
|
||||
for (const dungeon of dungeons) {
|
||||
for (const requirement of new Set(dungeon.reviewedFinding.requirements)) {
|
||||
requirementDungeonCounts[requirement] = (requirementDungeonCounts[requirement] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
schemaVersion: 2,
|
||||
generator: "scripts/runewaker-pipeline/analyze-navigation-gaps.mjs",
|
||||
methodology: {
|
||||
weldTolerance,
|
||||
liveStartSnapDistance: 3,
|
||||
liveObjectiveSnapDistance: 6,
|
||||
mutatesAssets: false,
|
||||
},
|
||||
resolutionSummary: {
|
||||
sourceAuthoredFixedOrders: dungeons.filter((dungeon) => dungeon.objectiveOrdering.authoritative).length,
|
||||
authoredOffMeshLinksAdded: 0,
|
||||
requirementDungeonCounts: Object.fromEntries(Object.entries(requirementDungeonCounts).sort()),
|
||||
},
|
||||
unreachableLegs: dungeons.flatMap((dungeon) => dungeon.legs).filter((leg) => !leg.reachable).length,
|
||||
dungeons,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
import { generateEncounterImplementationReport } from "./lib/encounter-implementation-report.mjs";
|
||||
|
||||
const result = await generateEncounterImplementationReport();
|
||||
console.log(JSON.stringify({
|
||||
validationStatus: result.report.validationStatus,
|
||||
completionStatus: result.report.completionStatus,
|
||||
reportJson: result.reportJson,
|
||||
reportMarkdown: result.reportMarkdown,
|
||||
summary: result.report.summary,
|
||||
issues: result.report.issues,
|
||||
}, null, 2));
|
||||
if (result.report.issues.length) process.exitCode = 1;
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env node
|
||||
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildWdbPlacementPlan } from "./lib/environment-assembly.mjs";
|
||||
import { sourceRootFor } from "./lib/recipe.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const recipeDirectory = path.join(root, "scripts/runewaker-pipeline/recipes");
|
||||
const reportJsonFile = path.join(
|
||||
root,
|
||||
"scripts/runewaker-pipeline/RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.json",
|
||||
);
|
||||
const reportMarkdownFile = path.join(
|
||||
root,
|
||||
"scripts/runewaker-pipeline/RUNEWAKER_ENVIRONMENT_ASSEMBLY_REPORT.md",
|
||||
);
|
||||
const projectPath = (file) => path.relative(root, file).split(path.sep).join("/");
|
||||
|
||||
async function exists(file) {
|
||||
try {
|
||||
await stat(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderMarkdown(report) {
|
||||
const rows = report.dungeons.map((dungeon) => [
|
||||
"|", dungeon.id, "|", dungeon.mode, "|", dungeon.sourceDescriptors, "|",
|
||||
dungeon.uniqueResources, "|", dungeon.staticPlacements, "|",
|
||||
dungeon.dynamicPlacements, "|", dungeon.dynamicReviewCandidates, "|",
|
||||
dungeon.unsupportedPlacements, "|", dungeon.missingSourceResources, "|",
|
||||
].join(" "));
|
||||
return [
|
||||
"# RuneWaker WDB environment assembly audit",
|
||||
"",
|
||||
"Structural validation: **" + report.validationStatus + "**",
|
||||
"Source-behavior review: **" + report.reviewStatus + "**",
|
||||
"",
|
||||
"This deterministic audit reads the checked-in WDB reports and environment recipes. It does",
|
||||
"not convert or replace shipping GLBs. The builder now supports every valid ROS placement",
|
||||
"resource with normalized-path model caching validated by source hashes and the complete translation/quaternion/scale",
|
||||
"transform relative to the reviewed primary placement.",
|
||||
"",
|
||||
"## Coverage",
|
||||
"",
|
||||
"- " + report.summary.dungeons + " dungeon WDB packages and "
|
||||
+ report.summary.sourceDescriptors + " source descriptors.",
|
||||
"- " + report.summary.uniqueDungeonResources
|
||||
+ " per-dungeon unique ROS resources (resources reused across dungeons share cache keys).",
|
||||
"- " + report.summary.staticPlacements + " static placements and "
|
||||
+ report.summary.dynamicPlacements + " explicitly reviewed dynamic placements.",
|
||||
"- " + report.summary.dynamicReviewCandidates
|
||||
+ " filename-based dynamic candidates remain static and review-required; names never prove behavior.",
|
||||
"- " + report.summary.unsupportedPlacements + " unsupported placement records and "
|
||||
+ report.summary.missingSourceResources + " missing source ROS resources.",
|
||||
"",
|
||||
"| Dungeon | Mode | Descriptors | Resources | Static | Dynamic | Dynamic review | Unsupported | Missing ROS |",
|
||||
"| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
|
||||
...rows,
|
||||
"",
|
||||
"## Runtime-facing output",
|
||||
"",
|
||||
"A full build writes `wdb-placement-manifest.json` beside the environment package. Each",
|
||||
"placement has a stable WDB GUID ID, normalized resource/cache key, source TRS, primary-local",
|
||||
"Three.js matrix, bounds, flags, asset status, and optional reviewed dynamic definition.",
|
||||
"Static placements are assembled into the optimized visual and navigation collision. Reviewed",
|
||||
"dynamic placements are omitted from static collision and packaged as separately addressable GLBs.",
|
||||
"",
|
||||
"## Unsupported or review-required behavior",
|
||||
"",
|
||||
...report.unsupportedResourceTypes.map((item) => "- " + item),
|
||||
"- The primary-only top-down preview and editable `.blend` do not include the final merged WDB",
|
||||
" assembly; the generated GLB and placement manifest are authoritative.",
|
||||
"- Dynamic-candidate filenames are not enough to remove geometry from the static package. Add a",
|
||||
" reviewed `source.wdbAssembly.dynamicPlacements` rule with evidence first.",
|
||||
"",
|
||||
"## Validation issues",
|
||||
"",
|
||||
...(report.issues.length ? report.issues.map((item) => "- " + item) : ["- None."]),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function buildEnvironmentAssemblyAudit() {
|
||||
const recipeNames = (await readdir(recipeDirectory))
|
||||
.filter((name) => name.endsWith(".json")
|
||||
&& !name.endsWith("-population.json")
|
||||
&& name !== "runewaker-player-avatars.json")
|
||||
.sort();
|
||||
const dungeons = [];
|
||||
const issues = [];
|
||||
for (const recipeName of recipeNames) {
|
||||
const recipe = JSON.parse(await readFile(path.join(recipeDirectory, recipeName), "utf8"));
|
||||
const wdbReportFile = path.join(
|
||||
root,
|
||||
"src/assets/game/dungeons",
|
||||
recipe.slug,
|
||||
"wdb-report.json",
|
||||
);
|
||||
if (!await exists(wdbReportFile)) {
|
||||
issues.push(recipe.slug + ": checked-in WDB report is missing.");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const wdbReport = JSON.parse(await readFile(wdbReportFile, "utf8"));
|
||||
const plan = buildWdbPlacementPlan(wdbReport, recipe);
|
||||
const resourceRoot = path.join(sourceRootFor(recipe), recipe.source.resourceRoot);
|
||||
const missingResources = [];
|
||||
for (const resource of plan.resources) {
|
||||
const file = path.join(resourceRoot, ...resource.normalizedResource.split("\\"));
|
||||
if (!await exists(file)) missingResources.push(resource.resource);
|
||||
}
|
||||
dungeons.push({
|
||||
id: recipe.slug,
|
||||
mode: recipe.source.proceduralTerrainFallback
|
||||
? "primary-only-procedural-fallback"
|
||||
: recipe.source.wdbAssembly?.mode ?? "all-ros",
|
||||
recipe: projectPath(path.join(recipeDirectory, recipeName)),
|
||||
wdbReport: projectPath(wdbReportFile),
|
||||
sourceDescriptors: plan.sourceDescriptorCount,
|
||||
uniqueResources: plan.resources.length,
|
||||
staticPlacements: plan.staticPlacements.length,
|
||||
dynamicPlacements: plan.dynamicPlacements.length,
|
||||
dynamicReviewCandidates: plan.reviewRequiredPlacements.length,
|
||||
unsupportedPlacements: plan.unsupportedPlacements.length,
|
||||
missingSourceResources: missingResources.length,
|
||||
missingResources,
|
||||
unsupported: plan.unsupportedPlacements,
|
||||
});
|
||||
} catch (error) {
|
||||
issues.push(recipe.slug + ": " + error.message);
|
||||
}
|
||||
}
|
||||
dungeons.sort((left, right) => left.id.localeCompare(right.id));
|
||||
const sum = (field) => dungeons.reduce((total, dungeon) => total + dungeon[field], 0);
|
||||
const unsupportedResourceTypes = [
|
||||
"WDB placement resources other than ROS are recorded but not converted.",
|
||||
"Particle, light, sound, camera, and script-only components absent from ROS mesh descriptors remain unsupported.",
|
||||
"Animated/stateful behavior remains unsupported until an exact reviewed dynamic-placement rule supplies its kind, initial state, collision policy, and evidence.",
|
||||
"A ROS that the preserved exporter cannot decode is skipped, recorded with its resource path and failure, and never replaced with guessed geometry.",
|
||||
];
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
generatedBy: "scripts/runewaker-pipeline/audit-environment-assembly.mjs",
|
||||
deterministic: true,
|
||||
validationStatus: issues.length ? "blocked" : "green",
|
||||
reviewStatus: sum("dynamicReviewCandidates") || sum("unsupportedPlacements")
|
||||
|| sum("missingSourceResources") ? "review-required" : "green",
|
||||
summary: {
|
||||
dungeons: dungeons.length,
|
||||
sourceDescriptors: sum("sourceDescriptors"),
|
||||
uniqueDungeonResources: sum("uniqueResources"),
|
||||
staticPlacements: sum("staticPlacements"),
|
||||
dynamicPlacements: sum("dynamicPlacements"),
|
||||
dynamicReviewCandidates: sum("dynamicReviewCandidates"),
|
||||
unsupportedPlacements: sum("unsupportedPlacements"),
|
||||
missingSourceResources: sum("missingSourceResources"),
|
||||
},
|
||||
unsupportedResourceTypes,
|
||||
issues,
|
||||
dungeons,
|
||||
};
|
||||
}
|
||||
|
||||
const report = await buildEnvironmentAssemblyAudit();
|
||||
await Promise.all([
|
||||
writeFile(reportJsonFile, JSON.stringify(report, null, 2) + "\n", "utf8"),
|
||||
writeFile(reportMarkdownFile, renderMarkdown(report), "utf8"),
|
||||
]);
|
||||
console.log(JSON.stringify({
|
||||
validationStatus: report.validationStatus,
|
||||
reviewStatus: report.reviewStatus,
|
||||
reportJson: projectPath(reportJsonFile),
|
||||
reportMarkdown: projectPath(reportMarkdownFile),
|
||||
summary: report.summary,
|
||||
issues: report.issues,
|
||||
}, null, 2));
|
||||
if (report.issues.length) process.exitCode = 1;
|
||||
@@ -7,6 +7,7 @@ import { BufferAttribute, BufferGeometry, Matrix4, Vector3 } from "three";
|
||||
import {
|
||||
buildPartyNavigationGraph,
|
||||
} from "../../src/game/partyNavigation.ts";
|
||||
import { generateEncounterImplementationReport } from "./lib/encounter-implementation-report.mjs";
|
||||
import { auditOrderedObjectiveRoutes } from "./lib/objective-route-audit.mjs";
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
@@ -156,6 +157,19 @@ function validateAttack(dungeonId, entityId, attack, issues) {
|
||||
}
|
||||
}
|
||||
|
||||
function formulaMatchesCatalog(attackFormula, catalogFormula) {
|
||||
if (!catalogFormula) return attackFormula === undefined;
|
||||
if (!attackFormula || typeof attackFormula !== "object") return false;
|
||||
const expected = {
|
||||
...catalogFormula,
|
||||
// NPC assignments carry their source spell level. Script-derived boss
|
||||
// references use the documented zero sentinel because no assignment level
|
||||
// exists in the packaged row.
|
||||
spellLevel: attackFormula.spellLevel,
|
||||
};
|
||||
return JSON.stringify(attackFormula) === JSON.stringify(expected);
|
||||
}
|
||||
|
||||
function renderMarkdown(report) {
|
||||
const rows = report.dungeons.map((dungeon) => [
|
||||
"|", dungeon.id, "|", dungeon.sourceRows, "|", dungeon.runtimeSpawns, "|",
|
||||
@@ -171,7 +185,7 @@ function renderMarkdown(report) {
|
||||
return [
|
||||
"# RuneWaker gameplay content audit",
|
||||
"",
|
||||
"Structural validation: **" + report.validationStatus + "** ",
|
||||
"Structural validation: **" + report.validationStatus + "**",
|
||||
"Original-mechanics fidelity: **" + report.fidelityStatus + "**",
|
||||
"",
|
||||
"This offline audit reads packaged recipes, snapshots, generated TypeScript, reports,",
|
||||
@@ -200,6 +214,10 @@ function renderMarkdown(report) {
|
||||
"- Boss signaling includes " + summary.telegraphedBossMechanics
|
||||
+ " spatial telegraphs and " + summary.healthTriggeredBossMechanics
|
||||
+ " visible health-triggered mechanics.",
|
||||
"- The exhaustive encounter implementation ledger tracks "
|
||||
+ report.encounterImplementationLedger.deferredSourceRows + " deferred source rows and "
|
||||
+ report.encounterImplementationLedger.uniqueMechanicsEvidence
|
||||
+ " unique mechanics evidence records without inflating overlapping regex labels.",
|
||||
"",
|
||||
"## Authentic source data currently used",
|
||||
"",
|
||||
@@ -215,10 +233,12 @@ function renderMarkdown(report) {
|
||||
"- " + 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.",
|
||||
"- Unmapped complex doors, multi-actor phases, patrols, resets, interactions, and source loot remain review-required.",
|
||||
"- Source-backed mapping preserves spell identity, damage family, school, range, cooldown,",
|
||||
" source power fields, and the exact native formula shape/provenance.",
|
||||
"- Damage execution uses a labeled HealerMan baseline adapter because native ATK/MATK,",
|
||||
" DEF/MDEF, weapon power, resist, critical, level-delta, and global rate inputs are unavailable.",
|
||||
"- Scaling, movement/leash, clustering, aggro, death state, loot item stats/slots, and objective ordering are generic.",
|
||||
"- Unmapped complex doors, multi-actor phases, patrols, resets, and interactions remain review-required.",
|
||||
"- Procedural animation states are HealerMan-authored; native GLBs must provide idle, move,",
|
||||
" attack, cast, wound, and death families.",
|
||||
"",
|
||||
@@ -530,11 +550,16 @@ for (const recipeName of recipeNames) {
|
||||
+ executableSpellId + ".");
|
||||
} else {
|
||||
counts.sourceBackedDamageFamilyAttacks += 1;
|
||||
if (attack.spellId && [
|
||||
const runtimeMappingDrift = !catalogAuthored && [
|
||||
"name", "school", "animation", "cooldownMs", "damageMultiplier",
|
||||
].some((field) => attack[field] !== (
|
||||
field === "name" ? catalogSpell.name : catalogSpell.runtime[field]
|
||||
))) {
|
||||
));
|
||||
const formulaDrift = !formulaMatchesCatalog(
|
||||
attack.runewakerFormula,
|
||||
catalogSpell.runtime.formula,
|
||||
);
|
||||
if (attack.spellId && (runtimeMappingDrift || formulaDrift)) {
|
||||
issue(issues, dungeonId, entityId + " spell " + executableSpellId
|
||||
+ " drifted from the portable source-backed runtime mapping.");
|
||||
}
|
||||
@@ -749,8 +774,8 @@ summary.portableDamageFamilyRecords = spellCatalogSnapshot?.damageFamilyCount ??
|
||||
summary.portableEvidenceOnlyRecords = spellCatalogSnapshot?.evidenceOnlyCount ?? 0;
|
||||
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.");
|
||||
reviewNotes.push("Source formula structure is preserved, but live damage uses a labeled HealerMan "
|
||||
+ "baseline adapter because the required native combat-stat inputs are not packaged.");
|
||||
const navigationBlockerEntries = dungeons.flatMap((dungeon) => (
|
||||
dungeon.objectiveRouteEvidence
|
||||
.filter((route) => route.orderAuthoritative && !route.reachable)
|
||||
@@ -784,6 +809,12 @@ if (summary.derivedObjectiveRoutes) {
|
||||
+ summary.unreachableDerivedObjectiveRoutes + " unreachable heuristic legs remain navigation/order review evidence.");
|
||||
}
|
||||
|
||||
const encounterImplementation = await generateEncounterImplementationReport();
|
||||
if (encounterImplementation.report.issues.length) {
|
||||
issues.push("Encounter implementation ledger is blocked: "
|
||||
+ encounterImplementation.report.issues.join("; "));
|
||||
}
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
validationStatus: issues.length ? "blocked" : "green",
|
||||
@@ -797,11 +828,11 @@ const report = {
|
||||
],
|
||||
genericRuntimeSystems: [
|
||||
"source-gap attacks", "health/damage scaling", "movement/leash", "clustering/aggro",
|
||||
"combat/death state", "loot policy", "objective ordering", "procedural animation",
|
||||
"combat/death state", "loot item stats/slots", "objective ordering", "procedural animation",
|
||||
],
|
||||
deferredMechanics: [
|
||||
"complex doors/gates", "unmapped multi-actor waves", "unmapped phases", "patrols", "interactions",
|
||||
"encounter resets", "source loot",
|
||||
"encounter resets", "blocked source-loot predicates", "source item stat/slot mapping",
|
||||
],
|
||||
blockerGroups: {
|
||||
navigation: {
|
||||
@@ -829,6 +860,14 @@ const report = {
|
||||
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.",
|
||||
},
|
||||
encounterImplementationLedger: {
|
||||
json: encounterImplementation.reportJson,
|
||||
markdown: encounterImplementation.reportMarkdown,
|
||||
validationStatus: encounterImplementation.report.validationStatus,
|
||||
completionStatus: encounterImplementation.report.completionStatus,
|
||||
deferredSourceRows: encounterImplementation.report.summary.deferredSourceRows,
|
||||
uniqueMechanicsEvidence: encounterImplementation.report.summary.uniqueMechanicsEvidence,
|
||||
},
|
||||
resolvedRuntimeObservations,
|
||||
documentedExceptions,
|
||||
issues,
|
||||
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
projectRoot,
|
||||
sourceRootFor,
|
||||
} from "./lib/recipe.mjs";
|
||||
import {
|
||||
assembleInstancedGlb,
|
||||
buildWdbPlacementPlan,
|
||||
normalizeWdbResourcePath,
|
||||
} from "./lib/environment-assembly.mjs";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const fallbackRecipe = path.join(pipelineDirectory, "recipes", "forsaken-abbey.json");
|
||||
@@ -23,6 +28,7 @@ const workDirectory = path.join(projectRoot, "runewaker-export-work", slug);
|
||||
const rawDirectory = path.join(workDirectory, "raw");
|
||||
const preparedDirectory = path.join(workDirectory, "prepared");
|
||||
const convertedDirectory = path.join(workDirectory, "converted");
|
||||
const resourceCacheDirectory = path.join(projectRoot, "runewaker-export-work", "resource-cache");
|
||||
const shippingDirectory = path.join(projectRoot, "src", "assets", "game", "dungeons", slug);
|
||||
const sourceRoot = sourceRootFor(recipe);
|
||||
const resourceRoot = path.join(sourceRoot, recipe.source.resourceRoot);
|
||||
@@ -45,6 +51,10 @@ const blenderScript = path.join(pipelineDirectory, "blender", "convert-runewaker
|
||||
const gltfTransform = path.join(projectRoot, "node_modules", "@gltf-transform", "cli", "bin", "cli.js");
|
||||
const ktx2Compressor = path.join(projectRoot, "scripts", "asset-pipeline", "compress-ktx2.mjs");
|
||||
const khronosValidator = path.join(projectRoot, "scripts", "manastorm-assets", "validate-package-glbs.cjs");
|
||||
const fullWdbAssembly = !recipe.source.proceduralTerrainFallback
|
||||
&& recipe.source.wdbAssembly?.mode !== "primary-only"
|
||||
&& !argv.includes("--primary-only");
|
||||
const collisionChunkTriangleTarget = 90_000;
|
||||
|
||||
function run(executable, argumentsList, label) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -67,6 +77,15 @@ async function requirePath(file, guidance) {
|
||||
catch { throw new Error(guidance + "\nMissing: " + file); }
|
||||
}
|
||||
|
||||
async function exists(file) {
|
||||
try {
|
||||
await stat(file);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256(file) {
|
||||
return createHash("sha256").update(await readFile(file)).digest("hex");
|
||||
}
|
||||
@@ -148,7 +167,7 @@ function sourcePointToPrimaryLocal(sourcePosition, placement) {
|
||||
|
||||
async function appendSourceAnchorSupport() {
|
||||
const support = recipe.source.sourceAnchorSupport;
|
||||
if (!support) return;
|
||||
if (!support || fullWdbAssembly) return;
|
||||
const { placement } = await primaryPlacement();
|
||||
const half = Number(support.halfExtentSourceUnits ?? 60);
|
||||
const thickness = Number(support.thicknessSourceUnits ?? 3);
|
||||
@@ -185,6 +204,128 @@ async function appendSourceAnchorSupport() {
|
||||
]);
|
||||
}
|
||||
|
||||
function resourceSourceFile(resource) {
|
||||
return path.join(resourceRoot, ...normalizeWdbResourcePath(resource).split("\\"));
|
||||
}
|
||||
|
||||
async function prepareCachedResourceTextures(raw, prepared) {
|
||||
const sourceTextures = path.join(raw, "textures");
|
||||
const preparedTextures = path.join(prepared, "textures");
|
||||
await mkdir(preparedTextures, { recursive: true });
|
||||
const ddsFiles = await exists(sourceTextures)
|
||||
? (await readdir(sourceTextures))
|
||||
.filter((file) => file.toLowerCase().endsWith(".dds"))
|
||||
.sort()
|
||||
.map((file) => path.join(sourceTextures, file))
|
||||
: [];
|
||||
if (ddsFiles.length) {
|
||||
await run(
|
||||
texconv,
|
||||
["-ft", "PNG", "-o", preparedTextures, "-y", "--permissive", ...ddsFiles],
|
||||
"convert " + ddsFiles.length + " cached DDS textures",
|
||||
);
|
||||
}
|
||||
await copyFile(path.join(raw, "scene.obj"), path.join(prepared, "scene.obj"));
|
||||
await copyFile(path.join(raw, "export-report.json"), path.join(prepared, "export-report.json"));
|
||||
const sourceMtl = await readFile(path.join(raw, "scene.mtl"), "utf8");
|
||||
await writeFile(
|
||||
path.join(prepared, "scene.mtl"),
|
||||
sourceMtl.replace(/^(map_Kd\s+.+)\.dds\s*$/gim, "$1.png"),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function cachedResourceAsset(resourceRecord) {
|
||||
const sourceFile = resourceSourceFile(resourceRecord.resource);
|
||||
if (!await exists(sourceFile)) {
|
||||
throw new Error("source ROS is missing: " + projectPath(sourceFile));
|
||||
}
|
||||
const sourceSha256 = await sha256(sourceFile);
|
||||
const cacheRoot = path.join(resourceCacheDirectory, resourceRecord.cacheKey);
|
||||
const raw = path.join(cacheRoot, "raw");
|
||||
const prepared = path.join(cacheRoot, "prepared");
|
||||
const converted = path.join(cacheRoot, "converted");
|
||||
const manifestFile = path.join(cacheRoot, "cache-manifest.json");
|
||||
if (!argv.includes("--force-resource-cache") && await exists(manifestFile)) {
|
||||
const cached = JSON.parse(await readFile(manifestFile, "utf8"));
|
||||
const outputs = [cached.visual?.file, ...(cached.collision ?? []).map((entry) => entry.file)]
|
||||
.filter(Boolean)
|
||||
.map((file) => path.join(cacheRoot, file));
|
||||
if (cached.source?.resource === resourceRecord.normalizedResource
|
||||
&& cached.source?.sha256 === sourceSha256
|
||||
&& cached.coordinateSystem?.metersPerSourceUnit === recipe.coordinateSystem.metersPerSourceUnit
|
||||
&& (await Promise.all(outputs.map(exists))).every(Boolean)) {
|
||||
return {
|
||||
...cached,
|
||||
cacheHit: true,
|
||||
visualFile: path.join(cacheRoot, cached.visual.file),
|
||||
collision: cached.collision.map((entry) => ({
|
||||
...entry,
|
||||
absoluteFile: path.join(cacheRoot, entry.file),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
await Promise.all([
|
||||
mkdir(raw, { recursive: true }),
|
||||
mkdir(prepared, { recursive: true }),
|
||||
mkdir(converted, { recursive: true }),
|
||||
]);
|
||||
await run(
|
||||
nativeExporter,
|
||||
[resourceRoot, resourceRecord.resource.replaceAll("/", "\\"), raw],
|
||||
"export cached WDB resource " + resourceRecord.resource,
|
||||
);
|
||||
await prepareCachedResourceTextures(raw, prepared);
|
||||
await run(
|
||||
blender,
|
||||
[
|
||||
"--background", "--factory-startup", "--python", blenderScript, "--",
|
||||
path.join(prepared, "scene.obj"),
|
||||
converted,
|
||||
resourceRecord.cacheKey,
|
||||
String(recipe.coordinateSystem.metersPerSourceUnit),
|
||||
],
|
||||
"convert cached WDB resource " + resourceRecord.resource,
|
||||
);
|
||||
const conversion = JSON.parse(await readFile(path.join(converted, "conversion-report.json"), "utf8"));
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
source: {
|
||||
resource: resourceRecord.normalizedResource,
|
||||
file: projectPath(sourceFile),
|
||||
sha256: sourceSha256,
|
||||
},
|
||||
coordinateSystem: {
|
||||
nativeBridge: recipe.coordinateSystem.nativeBridge,
|
||||
metersPerSourceUnit: recipe.coordinateSystem.metersPerSourceUnit,
|
||||
},
|
||||
visual: {
|
||||
file: path.relative(
|
||||
cacheRoot,
|
||||
path.join(converted, resourceRecord.cacheKey + "-visual.glb"),
|
||||
).split(path.sep).join("/"),
|
||||
triangles: conversion.visual.triangles,
|
||||
meshObjects: conversion.visual.meshObjects,
|
||||
bounds: conversion.visual.bounds,
|
||||
},
|
||||
collision: conversion.collision.map((entry) => ({
|
||||
...entry,
|
||||
file: path.relative(cacheRoot, path.join(converted, entry.file)).split(path.sep).join("/"),
|
||||
})),
|
||||
};
|
||||
await writeFile(manifestFile, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
||||
return {
|
||||
...manifest,
|
||||
cacheHit: false,
|
||||
visualFile: path.join(cacheRoot, manifest.visual.file),
|
||||
collision: manifest.collision.map((entry) => ({
|
||||
...entry,
|
||||
absoluteFile: path.join(cacheRoot, entry.file),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareTextures() {
|
||||
const sourceTextures = path.join(rawDirectory, "textures");
|
||||
const preparedTextures = path.join(preparedDirectory, "textures");
|
||||
@@ -252,8 +393,208 @@ function cloneNodeTree(document, node) {
|
||||
return clone;
|
||||
}
|
||||
|
||||
function groupCollisionRecords(records) {
|
||||
const groups = [];
|
||||
let current = [];
|
||||
let triangles = 0;
|
||||
for (const record of records) {
|
||||
if (current.length && triangles + record.triangles > collisionChunkTriangleTarget) {
|
||||
groups.push({ records: current, triangles });
|
||||
current = [];
|
||||
triangles = 0;
|
||||
}
|
||||
current.push(record);
|
||||
triangles += record.triangles;
|
||||
}
|
||||
if (current.length) groups.push({ records: current, triangles });
|
||||
return groups;
|
||||
}
|
||||
|
||||
async function assembleAllWdbPlacements() {
|
||||
const wdbReport = JSON.parse(await readFile(path.join(rawDirectory, "wdb-report.json"), "utf8"));
|
||||
const plan = buildWdbPlacementPlan(wdbReport, recipe);
|
||||
const primaryResource = normalizeWdbResourcePath(plan.primaryPlacement.resource);
|
||||
const primaryConversion = JSON.parse(
|
||||
await readFile(path.join(convertedDirectory, "conversion-report.json"), "utf8"),
|
||||
);
|
||||
const assets = new Map();
|
||||
assets.set(primaryResource, {
|
||||
cacheHit: true,
|
||||
cacheKind: "primary-conversion",
|
||||
visualFile: path.join(convertedDirectory, slug + "-visual.glb"),
|
||||
visual: primaryConversion.visual,
|
||||
collision: primaryConversion.collision.map((entry) => ({
|
||||
...entry,
|
||||
absoluteFile: path.join(convertedDirectory, entry.file),
|
||||
})),
|
||||
});
|
||||
const failures = [];
|
||||
for (const resource of plan.resources) {
|
||||
if (resource.normalizedResource === primaryResource) continue;
|
||||
try {
|
||||
assets.set(resource.normalizedResource, await cachedResourceAsset(resource));
|
||||
} catch (error) {
|
||||
failures.push({
|
||||
resource: resource.resource,
|
||||
normalizedResource: resource.normalizedResource,
|
||||
placementCount: resource.placementCount,
|
||||
reason: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
const unavailable = new Set(failures.map((failure) => failure.normalizedResource));
|
||||
for (const resource of plan.resources) {
|
||||
const asset = assets.get(resource.normalizedResource);
|
||||
resource.assetStatus = asset ? "available" : "unsupported";
|
||||
resource.cacheHit = asset?.cacheHit ?? false;
|
||||
resource.cacheKind = asset?.cacheKind ?? "normalized-path-cache-with-source-hash-validation";
|
||||
if (!asset) resource.blocker = failures.find((entry) => (
|
||||
entry.normalizedResource === resource.normalizedResource
|
||||
))?.reason;
|
||||
}
|
||||
for (const placement of [...plan.staticPlacements, ...plan.dynamicPlacements]) {
|
||||
placement.assetStatus = unavailable.has(placement.normalizedResource) ? "unsupported" : "available";
|
||||
placement.assetId = "wdb-resource:" + placement.resourceCacheKey;
|
||||
}
|
||||
|
||||
const staticPlacements = plan.staticPlacements.filter((placement) => (
|
||||
placement.assetStatus === "available"
|
||||
));
|
||||
if (!staticPlacements.length) throw new Error("WDB assembly produced no available static placements.");
|
||||
const visualRecords = staticPlacements.map((placement) => ({
|
||||
placement,
|
||||
assetFile: assets.get(placement.normalizedResource).visualFile,
|
||||
}));
|
||||
const assembledVisualFile = path.join(convertedDirectory, slug + "-visual.wdb-assembled.glb");
|
||||
const visualAssembly = await assembleInstancedGlb(visualRecords, assembledVisualFile);
|
||||
await copyFile(assembledVisualFile, path.join(convertedDirectory, slug + "-visual.glb"));
|
||||
|
||||
const collisionRecords = staticPlacements.flatMap((placement) => (
|
||||
assets.get(placement.normalizedResource).collision.map((collision) => ({
|
||||
placement,
|
||||
assetFile: collision.absoluteFile,
|
||||
triangles: Number(collision.triangles ?? collision.sourceTriangleEstimate ?? 0),
|
||||
}))
|
||||
)).sort((left, right) => (
|
||||
left.placement.id.localeCompare(right.placement.id)
|
||||
|| left.assetFile.localeCompare(right.assetFile)
|
||||
));
|
||||
const collision = [];
|
||||
for (const [index, group] of groupCollisionRecords(collisionRecords).entries()) {
|
||||
const file = slug + "-wdb-collision-" + String(index).padStart(3, "0") + ".glb";
|
||||
await assembleInstancedGlb(group.records, path.join(convertedDirectory, file));
|
||||
collision.push({
|
||||
file,
|
||||
triangles: group.triangles,
|
||||
sourceTriangleEstimate: group.triangles,
|
||||
placementCount: group.records.length,
|
||||
assembledFromWdb: true,
|
||||
});
|
||||
}
|
||||
|
||||
const dynamicDirectory = path.join(convertedDirectory, "dynamic");
|
||||
const dynamicAssets = [];
|
||||
const dynamicResources = plan.resources.filter((resource) => (
|
||||
resource.dynamicPlacementCount > 0 && resource.assetStatus === "available"
|
||||
));
|
||||
if (dynamicResources.length) await mkdir(dynamicDirectory, { recursive: true });
|
||||
for (const resource of dynamicResources) {
|
||||
const asset = assets.get(resource.normalizedResource);
|
||||
const visualName = resource.cacheKey + "-visual.glb";
|
||||
await copyFile(asset.visualFile, path.join(dynamicDirectory, visualName));
|
||||
dynamicAssets.push({
|
||||
role: "dynamic-visual",
|
||||
file: "dynamic/" + visualName,
|
||||
assetId: "wdb-resource:" + resource.cacheKey,
|
||||
resource: resource.resource,
|
||||
});
|
||||
for (const [index, sourceCollision] of asset.collision.entries()) {
|
||||
const collisionName = resource.cacheKey + "-collision-" + String(index).padStart(3, "0") + ".glb";
|
||||
await copyFile(sourceCollision.absoluteFile, path.join(dynamicDirectory, collisionName));
|
||||
dynamicAssets.push({
|
||||
role: "dynamic-collision",
|
||||
file: "dynamic/" + collisionName,
|
||||
assetId: "wdb-resource:" + resource.cacheKey,
|
||||
resource: resource.resource,
|
||||
triangles: Number(sourceCollision.triangles ?? 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
plan.resourceFailures = failures;
|
||||
plan.buildSummary = {
|
||||
staticPlacements: staticPlacements.length,
|
||||
dynamicPlacements: plan.dynamicPlacements.filter((placement) => (
|
||||
placement.assetStatus === "available"
|
||||
)).length,
|
||||
unavailablePlacements: [...plan.staticPlacements, ...plan.dynamicPlacements]
|
||||
.filter((placement) => placement.assetStatus === "unsupported").length,
|
||||
uniqueResources: plan.resources.length,
|
||||
availableResources: assets.size,
|
||||
unavailableResources: failures.length,
|
||||
resourceCacheHits: [...assets.values()].filter((asset) => (
|
||||
asset.cacheHit && asset.cacheKind !== "primary-conversion"
|
||||
)).length,
|
||||
};
|
||||
const placementManifestFile = path.join(convertedDirectory, "wdb-placement-manifest.json");
|
||||
await writeFile(placementManifestFile, JSON.stringify(plan, null, 2) + "\n", "utf8");
|
||||
|
||||
const visualTriangles = staticPlacements.reduce((total, placement) => (
|
||||
total + Number(assets.get(placement.normalizedResource).visual.triangles ?? 0)
|
||||
), 0);
|
||||
const visualMeshObjects = staticPlacements.reduce((total, placement) => (
|
||||
total + Number(assets.get(placement.normalizedResource).visual.meshObjects ?? 0)
|
||||
), 0);
|
||||
const conversion = {
|
||||
schemaVersion: 2,
|
||||
dungeonId: slug,
|
||||
status: failures.length || plan.reviewRequiredPlacements.length
|
||||
? "review-required"
|
||||
: "source-packaged",
|
||||
environmentMode: "runewaker-wdb-assembly",
|
||||
source: {
|
||||
wdb: recipe.source.wdb,
|
||||
primaryModel: recipe.source.model,
|
||||
descriptorCount: plan.sourceDescriptorCount,
|
||||
placementManifest: "wdb-placement-manifest.json",
|
||||
},
|
||||
coordinateTransform: plan.coordinateTransform,
|
||||
visual: {
|
||||
file: slug + "-visual.glb",
|
||||
meshObjects: visualMeshObjects,
|
||||
triangles: visualTriangles,
|
||||
sourceTriangles: visualTriangles,
|
||||
bounds: visualAssembly.bounds,
|
||||
assembledWdbPlacements: staticPlacements.length,
|
||||
uniqueResources: new Set(staticPlacements.map((placement) => placement.normalizedResource)).size,
|
||||
},
|
||||
collision,
|
||||
collisionPolicy: {
|
||||
kind: "wdb-static-placement-trimesh",
|
||||
maximumTrianglesPerChunk: 100_000,
|
||||
workingChunkTarget: collisionChunkTriangleTarget,
|
||||
excludesReviewedDynamicPlacements: true,
|
||||
includesUnreviewedDynamicCandidates: true,
|
||||
},
|
||||
dynamicAssets,
|
||||
preview: slug + "-preview.png",
|
||||
editableSource: slug + ".source.blend",
|
||||
wdbAssembly: plan.buildSummary,
|
||||
warnings: [
|
||||
"The preview and editable Blender source cover the primary ROS only; the shipping GLB is assembled from the WDB manifest.",
|
||||
"Name-based dynamic candidates remain static until a reviewed dynamic-placement rule supplies source evidence.",
|
||||
...failures.map((failure) => failure.resource + ": " + failure.reason),
|
||||
],
|
||||
};
|
||||
await writeFile(
|
||||
path.join(convertedDirectory, "conversion-report.json"),
|
||||
JSON.stringify(conversion, null, 2) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
async function assembleRepeatedPlacements() {
|
||||
const translations = assemblyTranslations();
|
||||
const translations = fullWdbAssembly ? [] : assemblyTranslations();
|
||||
if (!translations.length) return;
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
const visualFile = path.join(convertedDirectory, slug + "-visual.glb");
|
||||
@@ -385,7 +726,7 @@ async function bakeNavigation() {
|
||||
|
||||
async function appendSourceAnchorNavigation(navigationFile, reportFile) {
|
||||
const support = recipe.source.sourceAnchorSupport;
|
||||
if (!support) return;
|
||||
if (!support || fullWdbAssembly) return;
|
||||
const { placement } = await primaryPlacement();
|
||||
const half = Number(support.halfExtentSourceUnits ?? 60);
|
||||
const meters = recipe.coordinateSystem.metersPerSourceUnit;
|
||||
@@ -556,13 +897,29 @@ async function stageShippingPackage() {
|
||||
const files = [
|
||||
{ role: "visual", source: path.join(convertedDirectory, slug + "-visual.optimized.glb"), target: path.join(shippingDirectory, slug + "-visual.glb") },
|
||||
...conversion.collision.map((chunk) => ({ role: "collision", source: path.join(convertedDirectory, chunk.file), target: path.join(shippingDirectory, chunk.file) })),
|
||||
...(conversion.dynamicAssets ?? []).map((asset) => ({
|
||||
role: asset.role,
|
||||
source: path.join(convertedDirectory, asset.file),
|
||||
target: path.join(shippingDirectory, asset.file),
|
||||
assetId: asset.assetId,
|
||||
resource: asset.resource,
|
||||
})),
|
||||
{ role: "navigation", source: path.join(convertedDirectory, slug + "-navigation.glb"), target: path.join(shippingDirectory, slug + "-navigation.glb") },
|
||||
];
|
||||
for (const entry of files) await copyFile(entry.source, entry.target);
|
||||
for (const entry of files) {
|
||||
await mkdir(path.dirname(entry.target), { recursive: true });
|
||||
await copyFile(entry.source, entry.target);
|
||||
}
|
||||
for (const report of ["conversion-report.json", "optimization-report.json", "recast-report.json", slug + "-preview.png"]) {
|
||||
await copyFile(path.join(convertedDirectory, report), path.join(shippingDirectory, report));
|
||||
}
|
||||
await copyFile(path.join(rawDirectory, "wdb-report.json"), path.join(shippingDirectory, "wdb-report.json"));
|
||||
if (fullWdbAssembly) {
|
||||
await copyFile(
|
||||
path.join(convertedDirectory, "wdb-placement-manifest.json"),
|
||||
path.join(shippingDirectory, "wdb-placement-manifest.json"),
|
||||
);
|
||||
}
|
||||
const server = await sourceMetadata();
|
||||
const { report, placement } = await primaryPlacement();
|
||||
const sourceEntrance = [server.playerSpawn.x, server.playerSpawn.y, server.playerSpawn.z];
|
||||
@@ -604,12 +961,21 @@ async function stageShippingPackage() {
|
||||
} : {}),
|
||||
server,
|
||||
wdbReport: { descriptorCount: report.descriptorCount, file: "wdb-report.json", primaryPlacement: placement },
|
||||
...(fullWdbAssembly ? {
|
||||
wdbAssembly: {
|
||||
mode: "all-ros",
|
||||
placementManifest: "wdb-placement-manifest.json",
|
||||
...conversion.wdbAssembly,
|
||||
},
|
||||
} : {}),
|
||||
},
|
||||
coordinateSystem: recipe.coordinateSystem,
|
||||
entrance,
|
||||
assets: await Promise.all(files.map(async (entry) => ({
|
||||
role: entry.role,
|
||||
fileName: path.basename(entry.target),
|
||||
relativeFile: path.relative(shippingDirectory, entry.target).split(path.sep).join("/"),
|
||||
...(entry.assetId ? { assetId: entry.assetId, sourceResource: entry.resource } : {}),
|
||||
size: (await stat(entry.target)).size,
|
||||
sha256: await sha256(entry.target),
|
||||
}))),
|
||||
@@ -634,7 +1000,8 @@ await buildNativeExporterIfNeeded();
|
||||
await exportSource();
|
||||
await prepareTextures();
|
||||
await convertEnvironment();
|
||||
await assembleRepeatedPlacements();
|
||||
if (fullWdbAssembly) await assembleAllWdbPlacements();
|
||||
else await assembleRepeatedPlacements();
|
||||
await optimizeVisual();
|
||||
await bakeNavigation();
|
||||
await stageShippingPackage();
|
||||
|
||||
@@ -460,6 +460,12 @@ function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
|
||||
initialCooldownMs: [spell.runtime.cooldownMs, spell.runtime.cooldownMs],
|
||||
repeatCooldownMs: [spell.runtime.cooldownMs, spell.runtime.cooldownMs],
|
||||
damageMultiplier: spell.runtime.damageMultiplier,
|
||||
...(spell.runtime.formula ? {
|
||||
runewakerFormula: {
|
||||
...spell.runtime.formula,
|
||||
spellLevel: Number(sourceSpell.level ?? 0),
|
||||
},
|
||||
} : {}),
|
||||
}];
|
||||
});
|
||||
const recipeAttacks = Array.isArray(template.attacks)
|
||||
@@ -471,7 +477,34 @@ function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
|
||||
const mechanics = authored?.mode === "replace"
|
||||
? authored.mechanics ?? []
|
||||
: [...(authored?.mechanics ?? []), ...recipeAttacks, ...sourceAttacks];
|
||||
const unique = [...new Map(mechanics.map((attack) => [attack.id, attack])).values()];
|
||||
const sourceSpellLevels = new Map(
|
||||
(sourceSpells ?? []).map((sourceSpell) => [Number(sourceSpell.id), Number(sourceSpell.level ?? 0)]),
|
||||
);
|
||||
for (const spellId of authored?.source?.spellIds ?? []) {
|
||||
if (!sourceSpellLevels.has(Number(spellId))) {
|
||||
// Script-derived spell evidence has no NPC assignment level in the packaged row.
|
||||
// Zero is the catalog's explicit unassigned-level sentinel and is not used by
|
||||
// the HealerMan baseline adapter.
|
||||
sourceSpellLevels.set(Number(spellId), 0);
|
||||
}
|
||||
}
|
||||
const withSourceFormula = mechanics.map((attack) => {
|
||||
const spellId = Number(attack.spellId ?? attack.source?.spellId ?? 0);
|
||||
const spell = spellCatalog.get(spellId);
|
||||
if (!sourceSpellLevels.has(spellId)
|
||||
|| spell?.mappingStatus !== "damage-family"
|
||||
|| !spell.runtime?.formula) {
|
||||
return attack;
|
||||
}
|
||||
return {
|
||||
...attack,
|
||||
runewakerFormula: {
|
||||
...spell.runtime.formula,
|
||||
spellLevel: sourceSpellLevels.get(spellId),
|
||||
},
|
||||
};
|
||||
});
|
||||
const unique = [...new Map(withSourceFormula.map((attack) => [attack.id, attack])).values()];
|
||||
return [basicAttack, ...unique];
|
||||
}
|
||||
|
||||
@@ -706,7 +739,7 @@ async function generatePopulation() {
|
||||
serverEntryPolicy: "RuneWaker numeric ids are never passed to AzerothCore serverEntry.",
|
||||
sourceSpellCatalog: projectPath(spellCatalogFile),
|
||||
sourceSpellCatalogSha256: await sha256(spellCatalogFile),
|
||||
sourceSpellPolicy: "Only positively classified direct-HP-damage families execute; coefficients remain conservative HealerMan values.",
|
||||
sourceSpellPolicy: "Only positively classified direct-HP-damage families execute; source fields are preserved and live damage uses the labeled HealerMan baseline adapter when native stats are unavailable.",
|
||||
};
|
||||
const reportFile = resolveProject(recipe.files.report);
|
||||
await mkdir(path.dirname(reportFile), { recursive: true });
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import {
|
||||
FIDELITY_LEVELS,
|
||||
IMPLEMENTATION_STATUSES,
|
||||
buildEncounterImplementationReport,
|
||||
} from "./lib/encounter-implementation-report.mjs";
|
||||
|
||||
test("encounter ledger exhaustively classifies portable RuneWaker evidence", async () => {
|
||||
const report = await buildEncounterImplementationReport();
|
||||
assert.equal(report.validationStatus, "green", report.issues.join("\n"));
|
||||
assert.equal(report.summary.dungeons, 30);
|
||||
assert.equal(report.summary.deferredSourceRows, 4_113);
|
||||
assert.equal(report.summary.uniqueMechanicsEvidence, 1_524);
|
||||
assert.equal(report.summary.luaEvidenceSignals, 329);
|
||||
assert.equal(report.summary.templateEvidenceSignals, 1_195);
|
||||
|
||||
const inventory = report.dungeons.flatMap((dungeon) => [
|
||||
...dungeon.deferredSourceRows,
|
||||
...dungeon.mechanicsEvidence,
|
||||
]);
|
||||
assert.equal(new Set(inventory.map((item) => item.id)).size, inventory.length);
|
||||
for (const item of inventory) {
|
||||
assert.ok(IMPLEMENTATION_STATUSES.includes(item.status), item.id);
|
||||
assert.ok(FIDELITY_LEVELS.includes(item.fidelity), item.id);
|
||||
assert.ok(item.blocker, item.id);
|
||||
assert.ok(item.nextWork, item.id);
|
||||
assert.ok(item.clearingTest, item.id);
|
||||
}
|
||||
for (const row of report.dungeons.flatMap((dungeon) => dungeon.deferredSourceRows)) {
|
||||
assert.ok(row.sourceIds.spawnId > 0, row.id);
|
||||
assert.ok(row.sourceIds.templateId > 0, row.id);
|
||||
assert.ok(row.source.snapshot.path.endsWith(".json"), row.id);
|
||||
assert.match(row.source.snapshot.sha256, /^[a-f0-9]{64}$/, row.id);
|
||||
assert.match(row.source.snapshot.contentSha256, /^[a-f0-9]{64}$/, row.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("regex labels are memberships rather than inflated mechanic records", async () => {
|
||||
const report = await buildEncounterImplementationReport();
|
||||
const evidence = report.dungeons.flatMap((dungeon) => dungeon.mechanicsEvidence);
|
||||
const multiCategory = evidence.find((item) => item.categories.length > 1);
|
||||
assert.ok(multiCategory, "expected at least one source line with overlapping review labels");
|
||||
assert.equal(evidence.filter((item) => item.id === multiCategory.id).length, 1);
|
||||
assert.equal(
|
||||
report.summary.evidenceCategoryMembershipCount,
|
||||
evidence.reduce((total, item) => total + item.categories.length, 0),
|
||||
);
|
||||
assert.notEqual(report.summary.evidenceCategoryMembershipCount, evidence.length);
|
||||
});
|
||||
|
||||
test("source gaps, fallbacks, zero-boss packages, and empty variants stay explicit", async () => {
|
||||
const report = await buildEncounterImplementationReport();
|
||||
assert.deepEqual(
|
||||
report.specialCases.fallbackBosses.map((boss) => boss.entityId),
|
||||
["rw-fa-100070", "rw-fa-100561", "rw-fa-100562", "rw-fa-100563"],
|
||||
);
|
||||
assert.ok(report.specialCases.fallbackBosses.every((boss) => (
|
||||
boss.status === "implemented" && boss.fidelity === "healer-man-authored"
|
||||
)));
|
||||
assert.ok(report.specialCases.fallbackBosses.every((boss) => (
|
||||
boss.mechanicIds.length > 0
|
||||
&& boss.telegraphedMechanicIds.length === boss.mechanicIds.length
|
||||
)));
|
||||
assert.deepEqual(
|
||||
report.specialCases.nonEmptyZeroBossPackages.map((entry) => entry.dungeonId),
|
||||
[
|
||||
"aeternal-circle",
|
||||
"dungeon-of-dalanis",
|
||||
"grafu-castle",
|
||||
"kal-turok-hive",
|
||||
"lair-demon-dragon",
|
||||
"tomb-seven-heroes",
|
||||
"warnorken-arena",
|
||||
"zurhidon-stronghold",
|
||||
],
|
||||
);
|
||||
assert.deepEqual(
|
||||
report.specialCases.intentionalEmptyPackages.map((entry) => entry.dungeonId),
|
||||
["demon-stronghold-125", "zurhidon-stronghold-124"],
|
||||
);
|
||||
assert.equal(report.summary.deferredRowStatuses["deferred-source-gap"], 4_113);
|
||||
});
|
||||
|
||||
test("the report has no timestamps and is byte-deterministic in memory", async () => {
|
||||
const first = await buildEncounterImplementationReport();
|
||||
const second = await buildEncounterImplementationReport();
|
||||
assert.equal(JSON.stringify(first), JSON.stringify(second));
|
||||
assert.equal(Object.hasOwn(first, "generatedAt"), false);
|
||||
});
|
||||
|
||||
test("checked-in JSON is the current deterministic report", async () => {
|
||||
const built = await buildEncounterImplementationReport();
|
||||
const checkedIn = JSON.parse(await readFile(
|
||||
new URL("./RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json", import.meta.url),
|
||||
"utf8",
|
||||
));
|
||||
assert.deepEqual(checkedIn, built);
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { Document, NodeIO } from "@gltf-transform/core";
|
||||
import { Matrix4, Vector3 } from "three";
|
||||
import {
|
||||
assembleInstancedGlb,
|
||||
buildWdbPlacementPlan,
|
||||
normalizeWdbResourcePath,
|
||||
placementMatrixInPrimaryThree,
|
||||
resourceCacheKey,
|
||||
transformSourcePointToPrimaryThree,
|
||||
} from "./lib/environment-assembly.mjs";
|
||||
|
||||
const identityPlacement = (overrides = {}) => ({
|
||||
guid: "PRIMARY",
|
||||
resource: "model\\dungeon\\fixture\\room.ros",
|
||||
scale: [1, 1, 1],
|
||||
rotation: [0, 0, 0, 1],
|
||||
translation: [10, 20, 30],
|
||||
localBounds: { center: [0, 0, 0], radius: 1 },
|
||||
worldBounds: { center: [10, 20, 30], radius: 1 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const fixtureRecipe = (source = {}) => ({
|
||||
slug: "fixture",
|
||||
coordinateSystem: {
|
||||
source: "left-handed Y-up",
|
||||
nativeBridge: "three(x,y,z)=(runewaker.x,runewaker.y,-runewaker.z)",
|
||||
metersPerSourceUnit: 0.1,
|
||||
},
|
||||
source: {
|
||||
model: "model/dungeon/fixture/room.ros",
|
||||
primaryPlacementTranslation: [10, 20, 30],
|
||||
...source,
|
||||
},
|
||||
});
|
||||
|
||||
test("normalizes resource paths and makes case-insensitive stable cache keys", () => {
|
||||
assert.equal(
|
||||
normalizeWdbResourcePath("/Model/Dungeon/Fixture/Room.ROS"),
|
||||
"model\\dungeon\\fixture\\room.ros",
|
||||
);
|
||||
assert.equal(
|
||||
resourceCacheKey("Model/Dungeon/Fixture/Room.ROS"),
|
||||
resourceCacheKey("model\\dungeon\\fixture\\room.ros"),
|
||||
);
|
||||
});
|
||||
|
||||
test("converts full placement translation, quaternion, and nonuniform scale", () => {
|
||||
const primary = identityPlacement();
|
||||
const placed = identityPlacement({
|
||||
guid: "PLACED",
|
||||
resource: "model\\dungeon\\fixture\\bridge.ros",
|
||||
translation: [20, 22, 40],
|
||||
rotation: [0, Math.SQRT1_2, 0, Math.SQRT1_2],
|
||||
scale: [2, 3, 4],
|
||||
});
|
||||
const matrix = placementMatrixInPrimaryThree(placed, primary, 0.1);
|
||||
const sourcePoint = [3, 4, 5];
|
||||
const viaMatrix = new Vector3(sourcePoint[0] * 0.1, sourcePoint[1] * 0.1, -sourcePoint[2] * 0.1)
|
||||
.applyMatrix4(new Matrix4().fromArray(matrix.toArray()))
|
||||
.toArray();
|
||||
const direct = transformSourcePointToPrimaryThree(sourcePoint, placed, primary, 0.1);
|
||||
assert.deepEqual(viaMatrix.map((value) => Number(value.toFixed(9))), direct.map((value) => Number(value.toFixed(9))));
|
||||
assert.notDeepEqual(matrix.toArray(), new Matrix4().identity().toArray());
|
||||
});
|
||||
|
||||
test("builds deterministic static, dynamic, review, and unsupported placement groups", () => {
|
||||
const primary = identityPlacement();
|
||||
const door = identityPlacement({
|
||||
guid: "DOOR",
|
||||
resource: "model\\dungeon\\fixture\\boss_door.ros",
|
||||
translation: [15, 20, 30],
|
||||
});
|
||||
const hintedBridge = identityPlacement({
|
||||
guid: "BRIDGE",
|
||||
resource: "model\\dungeon\\fixture\\drawbridge.ros",
|
||||
translation: [20, 20, 30],
|
||||
});
|
||||
const unsupported = identityPlacement({
|
||||
guid: "FX",
|
||||
resource: "effect\\fixture\\mist.eft",
|
||||
});
|
||||
const recipe = fixtureRecipe({
|
||||
wdbAssembly: {
|
||||
mode: "all-ros",
|
||||
dynamicPlacements: [{
|
||||
id: "boss-door",
|
||||
guid: "DOOR",
|
||||
kind: "door",
|
||||
initialState: "closed",
|
||||
collision: "stateful",
|
||||
evidence: ["fixture.lua:10"],
|
||||
}],
|
||||
},
|
||||
});
|
||||
const first = buildWdbPlacementPlan({
|
||||
sourceWdb: "fixture.wdb",
|
||||
descriptors: [unsupported, hintedBridge, door, primary],
|
||||
}, recipe);
|
||||
const second = buildWdbPlacementPlan({
|
||||
sourceWdb: "fixture.wdb",
|
||||
descriptors: [unsupported, hintedBridge, door, primary],
|
||||
}, recipe);
|
||||
assert.deepEqual(first, second);
|
||||
assert.equal(first.staticPlacements.length, 2);
|
||||
assert.equal(first.dynamicPlacements.length, 1);
|
||||
assert.equal(first.dynamicPlacements[0].dynamic.kind, "door");
|
||||
assert.equal(first.reviewRequiredPlacements.length, 1);
|
||||
assert.equal(first.reviewRequiredPlacements[0].guid, "BRIDGE");
|
||||
assert.equal(first.unsupportedPlacements.length, 1);
|
||||
});
|
||||
|
||||
test("assembles repeated cached GLBs as transformed resource instances", async () => {
|
||||
const temporary = await mkdtemp(path.join(os.tmpdir(), "healer-man-wdb-assembly-"));
|
||||
try {
|
||||
const sourceFile = path.join(temporary, "source.glb");
|
||||
const outputFile = path.join(temporary, "assembled.glb");
|
||||
const source = new Document();
|
||||
const buffer = source.createBuffer("fixture-buffer");
|
||||
const position = source.createAccessor("fixture-position", buffer)
|
||||
.setType("VEC3")
|
||||
.setArray(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]));
|
||||
const primitive = source.createPrimitive().setAttribute("POSITION", position);
|
||||
const mesh = source.createMesh("fixture-mesh").addPrimitive(primitive);
|
||||
source.createScene("fixture-scene").addChild(source.createNode("fixture-node").setMesh(mesh));
|
||||
const io = new NodeIO();
|
||||
await io.write(sourceFile, source);
|
||||
const makePlacement = (id, x) => ({
|
||||
id,
|
||||
guid: id.toUpperCase(),
|
||||
resource: "model/dungeon/fixture/room.ros",
|
||||
resourceCacheKey: resourceCacheKey("model/dungeon/fixture/room.ros"),
|
||||
transform: {
|
||||
primaryLocalThreeMatrix: new Matrix4().makeTranslation(x, 0, 0).toArray(),
|
||||
},
|
||||
});
|
||||
const result = await assembleInstancedGlb([
|
||||
{ assetFile: sourceFile, placement: makePlacement("first", 0) },
|
||||
{ assetFile: sourceFile, placement: makePlacement("second", 5) },
|
||||
], outputFile);
|
||||
const assembled = await io.read(outputFile);
|
||||
const scene = assembled.getRoot().getDefaultScene() ?? assembled.getRoot().listScenes()[0];
|
||||
assert.deepEqual(scene.listChildren().map((node) => node.getName()), ["first", "second"]);
|
||||
assert.deepEqual(result.bounds, { min: [0, 0, 0], max: [6, 1, 0] });
|
||||
assert.equal(assembled.getRoot().listMeshes().length, 1, "repeated placements share cached mesh data");
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects dynamic rules that would remove the primary coordinate anchor", () => {
|
||||
const primary = identityPlacement();
|
||||
assert.throws(() => buildWdbPlacementPlan({ descriptors: [primary] }, fixtureRecipe({
|
||||
wdbAssembly: {
|
||||
dynamicPlacements: [{
|
||||
id: "invalid-primary-door",
|
||||
guid: "PRIMARY",
|
||||
kind: "door",
|
||||
evidence: ["fixture.lua:1"],
|
||||
}],
|
||||
},
|
||||
})), /primary coordinate-anchor placement cannot be dynamic/);
|
||||
});
|
||||
|
||||
test("current modular WDB reports account for every descriptor without guessed dynamics", async () => {
|
||||
for (const slug of ["raksha-temple", "the-origin", "treasure-trove"]) {
|
||||
const [recipe, report] = await Promise.all([
|
||||
readFile(new URL("./recipes/" + slug + ".json", import.meta.url), "utf8").then(JSON.parse),
|
||||
readFile(new URL("../../src/assets/game/dungeons/" + slug + "/wdb-report.json", import.meta.url), "utf8").then(JSON.parse),
|
||||
]);
|
||||
const plan = buildWdbPlacementPlan(report, recipe);
|
||||
assert.equal(
|
||||
plan.staticPlacements.length + plan.dynamicPlacements.length + plan.unsupportedPlacements.length,
|
||||
report.descriptorCount,
|
||||
slug,
|
||||
);
|
||||
assert.equal(plan.dynamicPlacements.length, 0, slug + " must not infer stateful behavior from names");
|
||||
assert.ok(plan.resources.length > 1, slug + " should expose arbitrary resource assembly");
|
||||
assert.equal(plan.primaryPlacement.resource.toLowerCase(), recipe.source.model.toLowerCase());
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,401 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
pipelineDirectory,
|
||||
projectPath,
|
||||
projectRoot,
|
||||
readJson,
|
||||
sourceRootFor,
|
||||
} from "./lib/recipe.mjs";
|
||||
|
||||
const DATA_START = 140;
|
||||
const COUNT_OFFSET = 132;
|
||||
const NPC_RECORD_SIZE = 2224;
|
||||
const NPC_OBJECT_TYPE = 1;
|
||||
const NPC_DROP_START = 0xf4;
|
||||
const NPC_DROP_COUNT = 15;
|
||||
const TREASURE_RECORD_SIZE = 1516;
|
||||
const TREASURE_OBJECT_TYPE = 20;
|
||||
const TREASURE_NEED_LEVEL_DELTA = 0xac;
|
||||
const TREASURE_ITEM_COUNT = 0xb0;
|
||||
const TREASURE_ITEMS_START = 0xb4;
|
||||
const TREASURE_MAX_ITEMS = 100;
|
||||
const TREASURE_LUA_START = 0x5ac;
|
||||
const TREASURE_LUA_LENGTH = 64;
|
||||
const RATE_DENOMINATOR = 100_000;
|
||||
const MAX_TREASURE_DEPTH = 12;
|
||||
|
||||
const recipesDirectory = path.join(pipelineDirectory, "recipes");
|
||||
const recipeNames = (await readdir(recipesDirectory))
|
||||
.filter((name) => name.endsWith("-population.json"))
|
||||
.sort();
|
||||
const recipes = await Promise.all(recipeNames.map(async (name) => (
|
||||
readJson(path.join(recipesDirectory, name))
|
||||
)));
|
||||
if (!recipes.length) throw new Error("No RuneWaker population recipes were found.");
|
||||
const sourceRoot = sourceRootFor(recipes[0]);
|
||||
const dataRoot = path.join(sourceRoot, recipes[0].source.resourceRoot ?? "Resource", "data");
|
||||
|
||||
const databaseNames = [
|
||||
"npcobject.db",
|
||||
"treasureobject.db",
|
||||
"itemobject.db",
|
||||
"armorobject.db",
|
||||
"weaponobject.db",
|
||||
"cardobject.db",
|
||||
"runeobject.db",
|
||||
"recipeobject.db",
|
||||
"keyitemobject.db",
|
||||
"string_enus.db",
|
||||
];
|
||||
const buffers = new Map(await Promise.all(databaseNames.map(async (name) => (
|
||||
[name, await readFile(path.join(dataRoot, name))]
|
||||
))));
|
||||
|
||||
function sha256(buffer) {
|
||||
return createHash("sha256").update(buffer).digest("hex");
|
||||
}
|
||||
|
||||
function int32(buffer, offset) {
|
||||
return buffer.readInt32LE(offset);
|
||||
}
|
||||
|
||||
function validateFixedCatalog(buffer, recordSize, objectType, label) {
|
||||
const count = int32(buffer, COUNT_OFFSET);
|
||||
if (count <= 0 || DATA_START + count * recordSize > buffer.length) {
|
||||
throw new Error(label + " has an incompatible record count or size.");
|
||||
}
|
||||
for (const index of [0, Math.floor(count / 2), count - 1]) {
|
||||
if (int32(buffer, DATA_START + index * recordSize + 4) !== objectType) {
|
||||
throw new Error(label + " object-type sentinel drifted.");
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function detectRecordSize(buffer, label) {
|
||||
const count = int32(buffer, COUNT_OFFSET);
|
||||
const objectType = int32(buffer, DATA_START + 4);
|
||||
for (let recordSize = 100; recordSize <= 20_000; recordSize += 4) {
|
||||
if (DATA_START + recordSize * count > buffer.length) continue;
|
||||
const sample = [1, 2, 3, Math.floor(count / 2), count - 1];
|
||||
if (sample.every((index) => (
|
||||
int32(buffer, DATA_START + index * recordSize + 4) === objectType
|
||||
))) return { count, objectType, recordSize };
|
||||
}
|
||||
throw new Error(label + " fixed-record layout could not be detected safely.");
|
||||
}
|
||||
|
||||
function indexCatalog(buffer, count, recordSize) {
|
||||
const offsets = new Map();
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const offset = DATA_START + index * recordSize;
|
||||
const id = int32(buffer, offset);
|
||||
if (id > 0) offsets.set(id, offset);
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
const npc = buffers.get("npcobject.db");
|
||||
const treasure = buffers.get("treasureobject.db");
|
||||
const strings = buffers.get("string_enus.db");
|
||||
const npcCount = validateFixedCatalog(npc, NPC_RECORD_SIZE, NPC_OBJECT_TYPE, "NPCObjectDB");
|
||||
const treasureCount = validateFixedCatalog(
|
||||
treasure, TREASURE_RECORD_SIZE, TREASURE_OBJECT_TYPE, "TreasureObjectDB",
|
||||
);
|
||||
const npcOffsets = indexCatalog(npc, npcCount, NPC_RECORD_SIZE);
|
||||
const treasureOffsets = indexCatalog(treasure, treasureCount, TREASURE_RECORD_SIZE);
|
||||
|
||||
const leafObjects = new Map();
|
||||
const leafCatalogs = [];
|
||||
for (const name of databaseNames.filter((candidate) => ![
|
||||
"npcobject.db", "treasureobject.db", "string_enus.db",
|
||||
].includes(candidate))) {
|
||||
const buffer = buffers.get(name);
|
||||
const layout = detectRecordSize(buffer, name);
|
||||
const offsets = indexCatalog(buffer, layout.count, layout.recordSize);
|
||||
leafCatalogs.push({ name, ...layout, count: offsets.size });
|
||||
for (const id of offsets.keys()) {
|
||||
if (!leafObjects.has(id)) leafObjects.set(id, { catalog: name, objectType: layout.objectType });
|
||||
}
|
||||
}
|
||||
|
||||
const stringCache = new Map();
|
||||
function stringValue(key) {
|
||||
if (stringCache.has(key)) return stringCache.get(key);
|
||||
const marker = Buffer.concat([Buffer.from(key, "ascii"), Buffer.from([0])]);
|
||||
const keyPosition = strings.indexOf(marker);
|
||||
if (keyPosition < 0) {
|
||||
stringCache.set(key, null);
|
||||
return null;
|
||||
}
|
||||
const start = keyPosition + marker.length;
|
||||
const end = Math.min(strings.length - 4, start + 512);
|
||||
for (let offset = start; offset < end; offset += 1) {
|
||||
const length = strings.readInt32LE(offset);
|
||||
if (length < 1 || length > 4001 || offset + 4 + length > strings.length) continue;
|
||||
const value = strings.subarray(offset + 4, offset + 3 + length);
|
||||
if (strings[offset + 3 + length] !== 0 || value.includes(0)) continue;
|
||||
try {
|
||||
const result = new TextDecoder("utf-8", { fatal: true }).decode(value).trim() || null;
|
||||
stringCache.set(key, result);
|
||||
return result;
|
||||
} catch {
|
||||
// Metadata can resemble a string length; continue to the next byte.
|
||||
}
|
||||
}
|
||||
stringCache.set(key, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
function fixedString(buffer, offset, length) {
|
||||
const end = buffer.indexOf(0, offset);
|
||||
const boundedEnd = end < 0 || end > offset + length ? offset + length : end;
|
||||
return buffer.subarray(offset, boundedEnd).toString("utf8").trim();
|
||||
}
|
||||
|
||||
function cleanProbability(value) {
|
||||
return Number(Math.max(0, Math.min(1, value)).toFixed(12));
|
||||
}
|
||||
|
||||
function blockedOutcome(objectId, chance, quantity, sourcePath, reason, constraints = {}) {
|
||||
return {
|
||||
objectId,
|
||||
name: stringValue("Sys" + objectId + "_name") ?? "RuneWaker Object " + objectId,
|
||||
sourceCatalog: null,
|
||||
runtimeStatus: "blocked",
|
||||
blockedReason: reason,
|
||||
conditionalChance: cleanProbability(chance),
|
||||
quantity,
|
||||
sourcePath,
|
||||
...constraints,
|
||||
};
|
||||
}
|
||||
|
||||
function expandObject(objectId, chance, quantity, sourcePath, stack = [], constraints = {}) {
|
||||
if (chance <= 0 || quantity <= 0) return [];
|
||||
const leaf = leafObjects.get(objectId);
|
||||
if (leaf) {
|
||||
return [{
|
||||
objectId,
|
||||
name: stringValue("Sys" + objectId + "_name") ?? "RuneWaker Item " + objectId,
|
||||
sourceCatalog: leaf.catalog,
|
||||
sourceObjectType: leaf.objectType,
|
||||
runtimeStatus: "executable-generic-item",
|
||||
blockedReason: null,
|
||||
conditionalChance: cleanProbability(chance),
|
||||
quantity,
|
||||
sourcePath,
|
||||
...constraints,
|
||||
}];
|
||||
}
|
||||
const offset = treasureOffsets.get(objectId);
|
||||
if (offset === undefined) {
|
||||
return [blockedOutcome(
|
||||
objectId, chance, quantity, sourcePath,
|
||||
"Object is absent from the bounded item and treasure source catalogs.", constraints,
|
||||
)];
|
||||
}
|
||||
if (stack.includes(objectId) || stack.length >= MAX_TREASURE_DEPTH) {
|
||||
return [blockedOutcome(
|
||||
objectId, chance, quantity, sourcePath,
|
||||
"Treasure recursion is cyclic or exceeds the audited depth limit.", constraints,
|
||||
)];
|
||||
}
|
||||
const luaCheck = fixedString(treasure, offset + TREASURE_LUA_START, TREASURE_LUA_LENGTH);
|
||||
if (luaCheck) {
|
||||
return [blockedOutcome(
|
||||
objectId, chance, quantity, sourcePath,
|
||||
"Treasure table requires unavailable Lua predicate " + luaCheck + ".",
|
||||
{ ...constraints, luaCheck },
|
||||
)];
|
||||
}
|
||||
const needLevelDelta = int32(treasure, offset + TREASURE_NEED_LEVEL_DELTA);
|
||||
const nextConstraints = {
|
||||
...constraints,
|
||||
minimumLevelDelta: Math.max(constraints.minimumLevelDelta ?? -2_147_483_648, needLevelDelta),
|
||||
};
|
||||
const itemCount = int32(treasure, offset + TREASURE_ITEM_COUNT);
|
||||
if (itemCount < 0 || itemCount > TREASURE_MAX_ITEMS) {
|
||||
return [blockedOutcome(
|
||||
objectId, chance, quantity, sourcePath,
|
||||
"Treasure item count is outside the preserved 0..100 layout.", nextConstraints,
|
||||
)];
|
||||
}
|
||||
const outcomes = [];
|
||||
for (let index = 0; index < itemCount; index += 1) {
|
||||
const itemOffset = offset + TREASURE_ITEMS_START + index * 12;
|
||||
const childId = int32(treasure, itemOffset);
|
||||
const childWeight = int32(treasure, itemOffset + 4);
|
||||
const childQuantity = int32(treasure, itemOffset + 8);
|
||||
if (childId <= 0 || childWeight <= 0 || childQuantity <= 0) continue;
|
||||
outcomes.push(...expandObject(
|
||||
childId,
|
||||
chance * Math.min(RATE_DENOMINATOR, childWeight) / RATE_DENOMINATOR,
|
||||
quantity * childQuantity,
|
||||
[...sourcePath, objectId],
|
||||
[...stack, objectId],
|
||||
nextConstraints,
|
||||
));
|
||||
}
|
||||
if (outcomes.length) return outcomes;
|
||||
return [blockedOutcome(
|
||||
objectId, chance, quantity, sourcePath,
|
||||
"Treasure table has no positive, resolvable outcomes.", nextConstraints,
|
||||
)];
|
||||
}
|
||||
|
||||
const bosses = [];
|
||||
for (const recipe of recipes) {
|
||||
const runtimePrefix = recipe.runtimeIdPrefix ?? "rw-" + recipe.dungeonId;
|
||||
for (const template of recipe.templates.filter((candidate) => candidate.classification === "boss")) {
|
||||
const bossKey = runtimePrefix + "-" + template.id;
|
||||
const npcOffset = npcOffsets.get(template.id);
|
||||
if (npcOffset === undefined) {
|
||||
bosses.push({
|
||||
bossKey,
|
||||
dungeonId: recipe.dungeonId,
|
||||
sourceNpcId: template.id,
|
||||
bossName: template.name,
|
||||
sourceLevel: Number(template.level ?? 1),
|
||||
status: "blocked",
|
||||
blockedReasons: ["NPCObjectDB record is missing."],
|
||||
rolls: [],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const rolls = [];
|
||||
for (let slot = 0; slot < NPC_DROP_COUNT; slot += 1) {
|
||||
const dropOffset = npcOffset + NPC_DROP_START + slot * 8;
|
||||
const sourceObjectId = int32(npc, dropOffset);
|
||||
const sourceRate = int32(npc, dropOffset + 4);
|
||||
if (sourceObjectId <= 0 || sourceRate <= 0) continue;
|
||||
const rootChance = cleanProbability(Math.min(RATE_DENOMINATOR, sourceRate) / RATE_DENOMINATOR);
|
||||
const outcomes = expandObject(sourceObjectId, 1, 1, []);
|
||||
rolls.push({
|
||||
sourceSlot: slot + 1,
|
||||
sourceObjectId,
|
||||
sourceRatePer100000: sourceRate,
|
||||
chancePerKill: rootChance,
|
||||
outcomes,
|
||||
});
|
||||
}
|
||||
const executable = rolls.flatMap((roll) => roll.outcomes)
|
||||
.filter((outcome) => outcome.runtimeStatus === "executable-generic-item");
|
||||
const blocked = rolls.flatMap((roll) => roll.outcomes)
|
||||
.filter((outcome) => outcome.runtimeStatus === "blocked");
|
||||
bosses.push({
|
||||
bossKey,
|
||||
dungeonId: recipe.dungeonId,
|
||||
sourceNpcId: template.id,
|
||||
bossName: template.name,
|
||||
sourceLevel: Number(template.level ?? 1),
|
||||
status: !rolls.length ? "no-source-drops" : blocked.length ? "partial" : "portable",
|
||||
blockedReasons: [...new Set(blocked.map((outcome) => outcome.blockedReason))],
|
||||
rolls,
|
||||
executableOutcomeCount: executable.length,
|
||||
blockedOutcomeCount: blocked.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
bosses.sort((left, right) => left.dungeonId.localeCompare(right.dungeonId)
|
||||
|| left.sourceNpcId - right.sourceNpcId);
|
||||
const sourceFiles = databaseNames.map((name) => ({
|
||||
file: projectPath(path.join(dataRoot, name)),
|
||||
sha256: sha256(buffers.get(name)),
|
||||
}));
|
||||
const catalog = {
|
||||
schemaVersion: 1,
|
||||
status: "portable-with-explicit-fallbacks",
|
||||
generatedFrom: {
|
||||
sourceFiles,
|
||||
npcDropAlgorithm: {
|
||||
file: "SourceCodeVS2019/Server/NetGameServer/MainProc/Global.cpp",
|
||||
firstRelevantLine: 4707,
|
||||
rateDenominator: RATE_DENOMINATOR,
|
||||
},
|
||||
treasureAlgorithm: {
|
||||
file: "SourceCodeVS2019/Libraries/Union/RoleData/RD_ItemProc.cxx",
|
||||
firstRelevantLine: 3033,
|
||||
rateDenominator: RATE_DENOMINATOR,
|
||||
},
|
||||
layout: "RuneWaker v0.20 fixed records; read-only export",
|
||||
},
|
||||
runtimePolicy: {
|
||||
rollsAreIndependent: true,
|
||||
treasureOutcomesAreMutuallyExclusive: true,
|
||||
unknownObjectsExecute: false,
|
||||
luaPredicatesExecute: false,
|
||||
sourceItemStatsInvented: false,
|
||||
importedItemsUseGenericNonEquipableInventoryRepresentation: true,
|
||||
},
|
||||
leafCatalogs,
|
||||
bossCount: bosses.length,
|
||||
bossWithSourceRollsCount: bosses.filter((boss) => boss.rolls.length).length,
|
||||
portableBossCount: bosses.filter((boss) => boss.status === "portable").length,
|
||||
partialBossCount: bosses.filter((boss) => boss.status === "partial").length,
|
||||
noSourceDropsBossCount: bosses.filter((boss) => boss.status === "no-source-drops").length,
|
||||
blockedBossCount: bosses.filter((boss) => boss.status === "blocked").length,
|
||||
executableOutcomeCount: bosses.reduce((total, boss) => total + (boss.executableOutcomeCount ?? 0), 0),
|
||||
blockedOutcomeCount: bosses.reduce((total, boss) => total + (boss.blockedOutcomeCount ?? 0), 0),
|
||||
bosses,
|
||||
};
|
||||
|
||||
const runtimeFile = path.join(projectRoot, "src", "game", "generated", "runewakerLootCatalog.json");
|
||||
const publicFile = path.join(projectRoot, "public", "data", "runewaker-loot-catalog.json");
|
||||
const reportFile = path.join(pipelineDirectory, "RUNEWAKER_LOOT_IMPORT_REPORT.json");
|
||||
const reportMarkdownFile = path.join(pipelineDirectory, "RUNEWAKER_LOOT_IMPORT_REPORT.md");
|
||||
await Promise.all([
|
||||
mkdir(path.dirname(runtimeFile), { recursive: true }),
|
||||
mkdir(path.dirname(publicFile), { recursive: true }),
|
||||
]);
|
||||
const serialized = JSON.stringify(catalog, null, 2) + "\n";
|
||||
const reportSummary = {
|
||||
...Object.fromEntries(Object.entries(catalog).filter(([key]) => key !== "bosses")),
|
||||
bosses: bosses.map((boss) => ({
|
||||
bossKey: boss.bossKey,
|
||||
dungeonId: boss.dungeonId,
|
||||
sourceNpcId: boss.sourceNpcId,
|
||||
bossName: boss.bossName,
|
||||
status: boss.status,
|
||||
sourceRollCount: boss.rolls.length,
|
||||
executableOutcomeCount: boss.executableOutcomeCount ?? 0,
|
||||
blockedOutcomeCount: boss.blockedOutcomeCount ?? 0,
|
||||
blockedReasons: boss.blockedReasons,
|
||||
})),
|
||||
};
|
||||
const markdown = [
|
||||
"# RuneWaker loot import report",
|
||||
"",
|
||||
"Generated from the preserved fixed-record databases. Source rates use the original 1-in-100,000 denominator.",
|
||||
"",
|
||||
`- Boss templates audited: ${catalog.bossCount}`,
|
||||
`- Bosses with source rolls: ${catalog.bossWithSourceRollsCount}`,
|
||||
`- Fully portable bosses: ${catalog.portableBossCount}`,
|
||||
`- Partially portable bosses: ${catalog.partialBossCount}`,
|
||||
`- Bosses with no authored drop slots: ${catalog.noSourceDropsBossCount}`,
|
||||
`- Executable item outcomes: ${catalog.executableOutcomeCount}`,
|
||||
`- Blocked outcomes: ${catalog.blockedOutcomeCount}`,
|
||||
"",
|
||||
"Unknown object relationships and Lua-gated treasure paths fail closed. Imported source items are collected as named, non-equipable generic items because the RuneWaker item-stat and equipment-slot conversion is not yet proven.",
|
||||
"",
|
||||
"| Dungeon | Boss | Status | Rolls | Executable | Blocked | Blocker |",
|
||||
"| --- | --- | --- | ---: | ---: | ---: | --- |",
|
||||
...reportSummary.bosses.map((boss) => (
|
||||
`| ${boss.dungeonId} | ${boss.bossName.replaceAll("|", "\\|")} | ${boss.status} | ${boss.sourceRollCount} | ${boss.executableOutcomeCount} | ${boss.blockedOutcomeCount} | ${(boss.blockedReasons.join("; ") || "—").replaceAll("|", "\\|")} |`
|
||||
)),
|
||||
"",
|
||||
].join("\n");
|
||||
await Promise.all([
|
||||
writeFile(runtimeFile, serialized, "utf8"),
|
||||
writeFile(publicFile, serialized, "utf8"),
|
||||
writeFile(reportFile, JSON.stringify(reportSummary, null, 2) + "\n", "utf8"),
|
||||
writeFile(reportMarkdownFile, markdown, "utf8"),
|
||||
]);
|
||||
console.log("[runewaker-loot] exported " + catalog.bossCount + " bosses, "
|
||||
+ catalog.executableOutcomeCount + " executable outcomes, and "
|
||||
+ catalog.blockedOutcomeCount + " blocked outcomes.");
|
||||
console.log("[runewaker-loot] " + projectPath(runtimeFile));
|
||||
@@ -147,6 +147,35 @@ function schoolFor(component) {
|
||||
?? "arcane";
|
||||
}
|
||||
|
||||
function formulaFor(spellId, component) {
|
||||
const magnitude = Math.abs(component.damagePower);
|
||||
const powerKind = component.damagePower < 0
|
||||
? magnitude <= 4 ? "source-ratio" : "source-points"
|
||||
: component.dotBase < 0
|
||||
? "dot-only"
|
||||
: "unresolved";
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
spellId,
|
||||
componentId: component.id,
|
||||
spellLevel: 0,
|
||||
calculationPath: component.effectType === 1 ? "physical" : "magic",
|
||||
powerKind,
|
||||
damagePower: component.damagePower,
|
||||
fixedValue: component.fixedValue,
|
||||
dotBase: component.dotBase,
|
||||
runtimeBaselineMultiplier: powerKind === "source-ratio"
|
||||
? Math.max(0.2, Math.min(4, magnitude))
|
||||
: powerKind === "unresolved" ? 0 : 0.85,
|
||||
provenance: {
|
||||
sourceFile: "SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp",
|
||||
physicalFunctionLine: 3366,
|
||||
magicFunctionLine: 3651,
|
||||
magicObjectDatabase: "Resource/data/magicobject.db",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const assignmentsBySpell = new Map();
|
||||
for (const recipe of recipes) {
|
||||
const snapshot = await readJson(path.resolve(projectRoot, recipe.files.snapshot));
|
||||
@@ -270,7 +299,8 @@ for (const [spellId, assignmentMap] of [...assignmentsBySpell].sort((left, right
|
||||
cooldownMs: Math.max(2500, Math.min(20000, sourceCooldownSeconds > 0
|
||||
? sourceCooldownSeconds * 1000
|
||||
: 6500)),
|
||||
damageMultiplier: 0.85,
|
||||
damageMultiplier: formulaFor(spellId, primaryDamage).runtimeBaselineMultiplier,
|
||||
formula: formulaFor(spellId, primaryDamage),
|
||||
},
|
||||
} : {}),
|
||||
components,
|
||||
@@ -298,6 +328,8 @@ const report = {
|
||||
runtimeSqlDependency: false,
|
||||
unknownEffectsExecute: false,
|
||||
coefficientsAreSourceExact: false,
|
||||
unavailableNativeInputsDefaulted: false,
|
||||
pointValuedDamageUsesLabeledScaledAdapter: true,
|
||||
},
|
||||
spells,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
|
||||
const recipeDirectory = path.join(projectRoot, "scripts/runewaker-pipeline/recipes");
|
||||
const reportJsonFile = path.join(
|
||||
projectRoot,
|
||||
"scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json",
|
||||
);
|
||||
const reportMarkdownFile = path.join(
|
||||
projectRoot,
|
||||
"scripts/runewaker-pipeline/RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.md",
|
||||
);
|
||||
|
||||
export const IMPLEMENTATION_STATUSES = Object.freeze([
|
||||
"implemented",
|
||||
"verified",
|
||||
"deferred-source-gap",
|
||||
"deferred-asset-gap",
|
||||
"deferred-runtime-gap",
|
||||
"excluded-non-gameplay",
|
||||
"intentional-empty",
|
||||
]);
|
||||
export const FIDELITY_LEVELS = Object.freeze([
|
||||
"source-exact",
|
||||
"source-backed-adapted",
|
||||
"healer-man-authored",
|
||||
]);
|
||||
|
||||
const EXPECTED_FALLBACK_BOSSES = Object.freeze([
|
||||
"rw-fa-100070",
|
||||
"rw-fa-100561",
|
||||
"rw-fa-100562",
|
||||
"rw-fa-100563",
|
||||
]);
|
||||
const EXPECTED_NON_EMPTY_ZERO_BOSS_PACKAGES = Object.freeze([
|
||||
"aeternal-circle",
|
||||
"dungeon-of-dalanis",
|
||||
"grafu-castle",
|
||||
"kal-turok-hive",
|
||||
"lair-demon-dragon",
|
||||
"tomb-seven-heroes",
|
||||
"warnorken-arena",
|
||||
"zurhidon-stronghold",
|
||||
]);
|
||||
const EXPECTED_INTENTIONAL_EMPTY_PACKAGES = Object.freeze([
|
||||
"demon-stronghold-125",
|
||||
"zurhidon-stronghold-124",
|
||||
]);
|
||||
|
||||
const SIGNAL_PATTERNS = Object.freeze([
|
||||
["summon", /summon|spawn.*npc|create.*npc|create.*monster|add.*npc/i],
|
||||
["door", /door|gate|seal|barrier|open.*object|close.*object/i],
|
||||
["timer", /timer|delay|sleep|wait|schedule/i],
|
||||
["movement", /moveto|move_to|patrol|waypoint|autoplot|path/i],
|
||||
["spell", /cast|magic|spell|skill/i],
|
||||
["death", /ondead|on_dead|dead|death|onkill|on_kill/i],
|
||||
["phase", /phase|stage|state|wave/i],
|
||||
["interaction", /click|useobject|talk|speak|interact|trigger/i],
|
||||
["loot", /loot|treasure|drop|reward|chest/i],
|
||||
]);
|
||||
|
||||
const readJson = async (file) => JSON.parse((await readFile(file, "utf8")).replace(/^\uFEFF/, ""));
|
||||
const projectPath = (file) => path.relative(projectRoot, file).split(path.sep).join("/");
|
||||
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
||||
const sha256File = async (file) => sha256(await readFile(file));
|
||||
const compareText = (left, right) => String(left).localeCompare(String(right));
|
||||
const sameMembers = (left, right) => (
|
||||
left.length === right.length && left.every((value, index) => value === right[index])
|
||||
);
|
||||
const constantCase = (value) => String(value).replaceAll("-", "_").toUpperCase();
|
||||
|
||||
function generatedJson(source, name, suffix) {
|
||||
const marker = "export const " + name + " = ";
|
||||
const start = source.indexOf(marker);
|
||||
if (start < 0) throw new Error("Missing generated constant " + name + ".");
|
||||
const valueStart = start + marker.length;
|
||||
const valueEnd = source.indexOf(suffix, valueStart);
|
||||
if (valueEnd < 0) throw new Error("Missing type suffix for " + name + ".");
|
||||
return JSON.parse(source.slice(valueStart, valueEnd));
|
||||
}
|
||||
|
||||
function runtimeTemplateId(entityId) {
|
||||
const match = String(entityId).match(/-(\d+)$/);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
function categoriesFor(value) {
|
||||
return SIGNAL_PATTERNS
|
||||
.filter(([, pattern]) => pattern.test(String(value ?? "")))
|
||||
.map(([kind]) => kind);
|
||||
}
|
||||
|
||||
function statusCounts(items) {
|
||||
return Object.fromEntries(IMPLEMENTATION_STATUSES.map((status) => [
|
||||
status,
|
||||
items.filter((item) => item.status === status).length,
|
||||
]));
|
||||
}
|
||||
|
||||
function fidelityCounts(items) {
|
||||
return Object.fromEntries(FIDELITY_LEVELS.map((fidelity) => [
|
||||
fidelity,
|
||||
items.filter((item) => item.fidelity === fidelity).length,
|
||||
]));
|
||||
}
|
||||
|
||||
function sourceFile(pathName, fileSha256, contentSha256 = null) {
|
||||
return {
|
||||
path: pathName,
|
||||
sha256: fileSha256,
|
||||
...(contentSha256 ? { contentSha256 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function deferredRowRecord({ dungeonId, zoneId, row, snapshotPath, snapshotFileSha256, snapshot }) {
|
||||
const evidenceText = [
|
||||
row.name,
|
||||
row.sourceName,
|
||||
row.modelPath,
|
||||
row.source?.roleName,
|
||||
row.source?.autoPlot,
|
||||
row.source?.plotClassName,
|
||||
...Object.values(row.source?.templateScripts ?? {}),
|
||||
].filter(Boolean).join(" ");
|
||||
return {
|
||||
id: "row:" + dungeonId + ":" + row.spawnId,
|
||||
kind: "deferred-source-row",
|
||||
dungeonId,
|
||||
zoneId,
|
||||
sourceIds: {
|
||||
spawnId: row.spawnId,
|
||||
templateId: row.templateId,
|
||||
roomId: Number(row.source?.roomId ?? 0),
|
||||
},
|
||||
name: row.name,
|
||||
sourceName: row.sourceName,
|
||||
sourceClassification: row.classification,
|
||||
sourcePosition: row.sourcePosition,
|
||||
direction: row.direction,
|
||||
modelPath: row.modelPath ?? null,
|
||||
heuristicCategories: categoriesFor(evidenceText),
|
||||
status: "deferred-source-gap",
|
||||
fidelity: "source-exact",
|
||||
source: {
|
||||
snapshot: sourceFile(snapshotPath, snapshotFileSha256, snapshot.contentSha256),
|
||||
},
|
||||
blocker: "The source row preserves identity and placement, but no reviewed evidence proves its gameplay behavior.",
|
||||
nextWork: "Identify the authoritative trigger, state transition, reset/persistence rules, and asset requirements; then add an explicit source-spawn mapping.",
|
||||
clearingTest: "Add a deterministic fixture proving that source spawn " + row.spawnId
|
||||
+ " executes the reviewed behavior and resets idempotently.",
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeSpellMatches(entities, templateId, spellId) {
|
||||
return Object.entries(entities).flatMap(([entityId, entity]) => {
|
||||
if (runtimeTemplateId(entityId) !== templateId) return [];
|
||||
return (entity.combat?.attacks ?? [])
|
||||
.filter((attack) => Number(attack.spellId ?? attack.source?.spellId ?? 0) === spellId)
|
||||
.map((attack) => ({
|
||||
entityId,
|
||||
attackId: attack.id,
|
||||
telegraph: attack.telegraph ?? null,
|
||||
delivery: attack.delivery,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function baseEvidenceRecord({
|
||||
id,
|
||||
evidenceKind,
|
||||
dungeonId,
|
||||
zoneId,
|
||||
templateId = null,
|
||||
categories,
|
||||
value,
|
||||
source,
|
||||
}) {
|
||||
return {
|
||||
id,
|
||||
kind: "mechanics-evidence",
|
||||
evidenceKind,
|
||||
dungeonId,
|
||||
zoneId,
|
||||
sourceIds: templateId == null ? {} : { templateId },
|
||||
categories: [...new Set(categories)].sort(compareText),
|
||||
value,
|
||||
status: "deferred-source-gap",
|
||||
fidelity: "source-exact",
|
||||
source,
|
||||
blocker: "This is source evidence, but it does not prove a complete executable mechanic by itself.",
|
||||
nextWork: "Review the surrounding source to establish trigger, target, timing, state effects, reset, persistence, and multiplayer behavior.",
|
||||
clearingTest: "Add a focused encounter fixture tied to this stable evidence ID and verify its full state transition and reset behavior.",
|
||||
};
|
||||
}
|
||||
|
||||
function templateEvidenceRecords({
|
||||
dungeonId,
|
||||
zoneId,
|
||||
mechanics,
|
||||
mechanicsPath,
|
||||
mechanicsFileSha256,
|
||||
snapshot,
|
||||
snapshotPath,
|
||||
snapshotFileSha256,
|
||||
entities,
|
||||
}) {
|
||||
const records = [];
|
||||
const commonSource = {
|
||||
mechanicsAudit: sourceFile(mechanicsPath, mechanicsFileSha256),
|
||||
snapshot: sourceFile(snapshotPath, snapshotFileSha256, snapshot.contentSha256),
|
||||
};
|
||||
for (const candidate of mechanics.templateCandidates ?? []) {
|
||||
const templateId = Number(candidate.templateId);
|
||||
for (const value of candidate.autoPlots ?? []) {
|
||||
records.push(baseEvidenceRecord({
|
||||
id: "signal:" + dungeonId + ":template:" + templateId + ":autoplot:" + sha256(value).slice(0, 12),
|
||||
evidenceKind: "auto-plot",
|
||||
dungeonId,
|
||||
zoneId,
|
||||
templateId,
|
||||
categories: categoriesFor(value),
|
||||
value,
|
||||
source: commonSource,
|
||||
}));
|
||||
}
|
||||
for (const value of candidate.plotClassNames ?? []) {
|
||||
records.push(baseEvidenceRecord({
|
||||
id: "signal:" + dungeonId + ":template:" + templateId + ":plot-class:" + sha256(value).slice(0, 12),
|
||||
evidenceKind: "plot-class",
|
||||
dungeonId,
|
||||
zoneId,
|
||||
templateId,
|
||||
categories: categoriesFor(value),
|
||||
value,
|
||||
source: commonSource,
|
||||
}));
|
||||
}
|
||||
for (const script of candidate.templateScripts ?? []) {
|
||||
const value = script.event + ":" + script.script;
|
||||
records.push(baseEvidenceRecord({
|
||||
id: "signal:" + dungeonId + ":template:" + templateId + ":script:"
|
||||
+ script.event + ":" + sha256(script.script).slice(0, 12),
|
||||
evidenceKind: "template-script",
|
||||
dungeonId,
|
||||
zoneId,
|
||||
templateId,
|
||||
categories: categoriesFor(value),
|
||||
value: { event: script.event, script: script.script },
|
||||
source: commonSource,
|
||||
}));
|
||||
}
|
||||
for (const spell of candidate.spells ?? []) {
|
||||
const spellId = Number(spell.id);
|
||||
const matches = runtimeSpellMatches(entities, templateId, spellId);
|
||||
const record = baseEvidenceRecord({
|
||||
id: "signal:" + dungeonId + ":template:" + templateId + ":spell:" + spellId,
|
||||
evidenceKind: "source-spell",
|
||||
dungeonId,
|
||||
zoneId,
|
||||
templateId,
|
||||
categories: ["spell"],
|
||||
value: { spellId, level: Number(spell.level ?? 0) },
|
||||
source: commonSource,
|
||||
});
|
||||
if (matches.length) {
|
||||
record.status = "verified";
|
||||
record.fidelity = "source-backed-adapted";
|
||||
record.runtimeMappings = matches;
|
||||
record.blocker = "The original RuneWaker coefficient and native stat inputs are not available in the portable package.";
|
||||
record.nextWork = "Retain the exact spell identity and revisit numerical fidelity only if authoritative formula inputs are recovered.";
|
||||
record.clearingTest = "npm run runewaker:audit:gameplay verifies the source spell ID and runtime attack remain aligned.";
|
||||
}
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function luaEvidenceRecords({ dungeonId, zoneId, mechanics, mechanicsPath, mechanicsFileSha256 }) {
|
||||
const luaHashByFile = new Map(
|
||||
(mechanics.luaSources ?? []).map((source) => [source.file, source.sha256]),
|
||||
);
|
||||
return (mechanics.luaSignals ?? []).map((signal) => baseEvidenceRecord({
|
||||
id: "signal:" + dungeonId + ":lua:" + signal.file + ":" + signal.line,
|
||||
evidenceKind: "lua-line",
|
||||
dungeonId,
|
||||
zoneId,
|
||||
categories: signal.kinds ?? [],
|
||||
value: { line: signal.line, text: signal.text },
|
||||
source: {
|
||||
mechanicsAudit: sourceFile(mechanicsPath, mechanicsFileSha256),
|
||||
lua: sourceFile(signal.file, luaHashByFile.get(signal.file) ?? null),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function renderMarkdown(report) {
|
||||
const rows = report.dungeons.map((dungeon) => [
|
||||
"|", dungeon.id, "|", dungeon.sourceRows, "|", dungeon.deferredSourceRows.length, "|",
|
||||
dungeon.mechanicsEvidence.length, "|", dungeon.evidenceStatusCounts.verified, "|",
|
||||
dungeon.evidenceStatusCounts["deferred-source-gap"], "|", dungeon.bossObjectives, "|",
|
||||
dungeon.packageAssessment.status, "|",
|
||||
].join(" "));
|
||||
const categoryRows = Object.entries(report.summary.evidenceCategoryMemberships)
|
||||
.map(([kind, count]) => "| " + kind + " | " + count + " |");
|
||||
const blockerRows = Object.entries(report.blockerGroups)
|
||||
.map(([key, value]) => "| " + key + " | " + value.items + " | " + value.nextWork + " |");
|
||||
return [
|
||||
"# RuneWaker encounter implementation and fidelity ledger",
|
||||
"",
|
||||
"Structural validation: **" + report.validationStatus + "**",
|
||||
"Content completion: **" + report.completionStatus + "**",
|
||||
"",
|
||||
"This report is deterministic and offline. Its JSON companion inventories every deferred",
|
||||
"source spawn row and every direct mechanics evidence record. A regex category membership",
|
||||
"is a review label, not a unique mechanic; one evidence record may belong to several categories.",
|
||||
"",
|
||||
"## Coverage",
|
||||
"",
|
||||
"- " + report.summary.dungeons + " dungeon packages.",
|
||||
"- " + report.summary.deferredSourceRows + " deferred source rows with stable spawn IDs.",
|
||||
"- " + report.summary.uniqueMechanicsEvidence + " unique mechanics evidence records: "
|
||||
+ report.summary.luaEvidenceSignals + " Lua lines and "
|
||||
+ report.summary.templateEvidenceSignals + " template references.",
|
||||
"- " + report.summary.evidenceCategoryMembershipCount
|
||||
+ " overlapping category memberships; this number is intentionally not reported as mechanics.",
|
||||
"- " + report.summary.verifiedEvidence + " evidence records have an exact executable source-spell mapping.",
|
||||
"- " + report.summary.sourceGapEvidence + " evidence records remain source-review gaps.",
|
||||
"",
|
||||
"## Status definitions",
|
||||
"",
|
||||
"- `verified`: an exact source identifier is present in executable runtime data and checked by the audit.",
|
||||
"- `implemented`: executable behavior exists, but source fidelity is incomplete or explicitly authored.",
|
||||
"- `deferred-source-gap`: source context does not prove the full behavior; the generator never guesses it.",
|
||||
"- `deferred-asset-gap`: behavior is understood but a required asset is unavailable.",
|
||||
"- `deferred-runtime-gap`: behavior is understood but the runtime primitive is unavailable.",
|
||||
"- `excluded-non-gameplay`: a reviewed source item is confirmed to have no gameplay behavior.",
|
||||
"- `intentional-empty`: the authoritative packaged snapshot contains no population rows.",
|
||||
"",
|
||||
"## Per-dungeon inventory",
|
||||
"",
|
||||
"| Dungeon | Source rows | Deferred rows | Evidence | Verified | Source gaps | Bosses | Package status |",
|
||||
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
|
||||
...rows,
|
||||
"",
|
||||
"## Four explicit fallback bosses",
|
||||
"",
|
||||
"| Boss | Runtime ID | Status | Fidelity | Blocker |",
|
||||
"| --- | --- | --- | --- | --- |",
|
||||
...report.specialCases.fallbackBosses.map((boss) => "| " + boss.name + " | `" + boss.entityId
|
||||
+ "` | " + boss.status + " | " + boss.fidelity + " | " + boss.blocker + " |"),
|
||||
"",
|
||||
"These attacks are intentionally playable and telegraphed HealerMan behavior. They are not",
|
||||
"presented as original RuneWaker mechanics until authoritative source behavior is recovered.",
|
||||
"",
|
||||
"## Non-empty packages without confirmed boss objectives",
|
||||
"",
|
||||
...report.specialCases.nonEmptyZeroBossPackages.map((entry) => "- `" + entry.dungeonId + "`: "
|
||||
+ entry.sourceRows + " rows; " + entry.blocker + " Next: " + entry.nextWork),
|
||||
"",
|
||||
"## Intentional empty variants",
|
||||
"",
|
||||
...report.specialCases.intentionalEmptyPackages.map((entry) => "- `" + entry.dungeonId + "` (Zone "
|
||||
+ entry.zoneId + "): " + entry.blocker),
|
||||
"",
|
||||
"## Evidence category memberships",
|
||||
"",
|
||||
"| Category | Evidence records carrying the label |",
|
||||
"| --- | ---: |",
|
||||
...categoryRows,
|
||||
"",
|
||||
"## Remaining work ledger",
|
||||
"",
|
||||
"| Blocker group | Items | Clearing work |",
|
||||
"| --- | ---: | --- |",
|
||||
...blockerRows,
|
||||
"",
|
||||
"The exhaustive per-item blocker, next-work instruction, clearing test, source IDs, source",
|
||||
"files, and SHA-256 hashes are in `RUNEWAKER_ENCOUNTER_IMPLEMENTATION_REPORT.json`.",
|
||||
"",
|
||||
"## Validation issues",
|
||||
"",
|
||||
...(report.issues.length ? report.issues.map((entry) => "- " + entry) : ["- None."]),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function validateReport(report) {
|
||||
const issues = [];
|
||||
const inventory = report.dungeons.flatMap((dungeon) => [
|
||||
...dungeon.deferredSourceRows,
|
||||
...dungeon.mechanicsEvidence,
|
||||
]);
|
||||
const ids = new Set();
|
||||
for (const item of inventory) {
|
||||
if (ids.has(item.id)) issues.push("Duplicate inventory ID: " + item.id + ".");
|
||||
ids.add(item.id);
|
||||
if (!IMPLEMENTATION_STATUSES.includes(item.status)) {
|
||||
issues.push(item.id + " has unsupported status " + item.status + ".");
|
||||
}
|
||||
if (!FIDELITY_LEVELS.includes(item.fidelity)) {
|
||||
issues.push(item.id + " has unsupported fidelity " + item.fidelity + ".");
|
||||
}
|
||||
if (!item.blocker || !item.nextWork || !item.clearingTest) {
|
||||
issues.push(item.id + " does not document blocker, next work, and clearing test.");
|
||||
}
|
||||
}
|
||||
const fallbackIds = report.specialCases.fallbackBosses.map((boss) => boss.entityId).sort(compareText);
|
||||
if (!sameMembers(fallbackIds, [...EXPECTED_FALLBACK_BOSSES])) {
|
||||
issues.push("Expected exactly the four documented Forsaken Abbey fallback bosses; found "
|
||||
+ fallbackIds.join(", ") + ".");
|
||||
}
|
||||
const zeroBossIds = report.specialCases.nonEmptyZeroBossPackages
|
||||
.map((entry) => entry.dungeonId).sort(compareText);
|
||||
if (!sameMembers(zeroBossIds, [...EXPECTED_NON_EMPTY_ZERO_BOSS_PACKAGES])) {
|
||||
issues.push("Non-empty zero-boss package set drifted: " + zeroBossIds.join(", ") + ".");
|
||||
}
|
||||
const emptyIds = report.specialCases.intentionalEmptyPackages
|
||||
.map((entry) => entry.dungeonId).sort(compareText);
|
||||
if (!sameMembers(emptyIds, [...EXPECTED_INTENTIONAL_EMPTY_PACKAGES])) {
|
||||
issues.push("Intentional-empty package set drifted: " + emptyIds.join(", ") + ".");
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export async function buildEncounterImplementationReport() {
|
||||
const recipeNames = (await readdir(recipeDirectory))
|
||||
.filter((name) => name.endsWith("-population.json"))
|
||||
.sort(compareText);
|
||||
const bossMechanicsPath = path.join(projectRoot, "dungeon-pipeline/boss-mechanics.json");
|
||||
const [bossMechanics, bossMechanicsFileSha256] = await Promise.all([
|
||||
readJson(bossMechanicsPath),
|
||||
sha256File(bossMechanicsPath),
|
||||
]);
|
||||
const dungeons = [];
|
||||
|
||||
for (const recipeName of recipeNames) {
|
||||
const recipeFile = path.join(recipeDirectory, recipeName);
|
||||
const recipe = await readJson(recipeFile);
|
||||
const dungeonId = recipe.dungeonId;
|
||||
const zoneId = recipe.zoneId;
|
||||
const snapshotFile = path.resolve(projectRoot, recipe.files.snapshot);
|
||||
const mechanicsFile = path.resolve(
|
||||
projectRoot,
|
||||
recipe.files.mechanicsReport
|
||||
?? "src/assets/game/dungeons/" + dungeonId + "/mechanics-audit.json",
|
||||
);
|
||||
const generatedSourceFile = path.resolve(projectRoot, recipe.files.generatedSource);
|
||||
const [
|
||||
recipeFileSha256,
|
||||
snapshot,
|
||||
snapshotFileSha256,
|
||||
mechanics,
|
||||
mechanicsFileSha256,
|
||||
generatedSource,
|
||||
generatedSourceSha256,
|
||||
] = await Promise.all([
|
||||
sha256File(recipeFile),
|
||||
readJson(snapshotFile),
|
||||
sha256File(snapshotFile),
|
||||
readJson(mechanicsFile),
|
||||
sha256File(mechanicsFile),
|
||||
readFile(generatedSourceFile, "utf8"),
|
||||
sha256File(generatedSourceFile),
|
||||
]);
|
||||
const prefix = constantCase(dungeonId);
|
||||
const entities = generatedJson(
|
||||
generatedSource,
|
||||
prefix + "_ENTITIES",
|
||||
" as const satisfies PopulationDefinitionMap;",
|
||||
);
|
||||
const bosses = generatedJson(
|
||||
generatedSource,
|
||||
prefix + "_BOSSES",
|
||||
" as const satisfies readonly DungeonBossObjective[];",
|
||||
);
|
||||
const snapshotPath = projectPath(snapshotFile);
|
||||
const mechanicsPath = projectPath(mechanicsFile);
|
||||
const deferredSourceRows = (snapshot.rows ?? [])
|
||||
.filter((row) => String(row.classification).startsWith("deferred"))
|
||||
.map((row) => deferredRowRecord({
|
||||
dungeonId,
|
||||
zoneId,
|
||||
row,
|
||||
snapshotPath,
|
||||
snapshotFileSha256,
|
||||
snapshot,
|
||||
}))
|
||||
.sort((left, right) => left.sourceIds.spawnId - right.sourceIds.spawnId);
|
||||
const mechanicsEvidence = [
|
||||
...luaEvidenceRecords({
|
||||
dungeonId,
|
||||
zoneId,
|
||||
mechanics,
|
||||
mechanicsPath,
|
||||
mechanicsFileSha256,
|
||||
}),
|
||||
...templateEvidenceRecords({
|
||||
dungeonId,
|
||||
zoneId,
|
||||
mechanics,
|
||||
mechanicsPath,
|
||||
mechanicsFileSha256,
|
||||
snapshot,
|
||||
snapshotPath,
|
||||
snapshotFileSha256,
|
||||
entities,
|
||||
}),
|
||||
].sort((left, right) => compareText(left.id, right.id));
|
||||
const packageStatus = snapshot.rows.length === 0
|
||||
? "intentional-empty"
|
||||
: bosses.length === 0
|
||||
? "deferred-source-gap"
|
||||
: "verified";
|
||||
dungeons.push({
|
||||
id: dungeonId,
|
||||
zoneId,
|
||||
sourceRows: snapshot.rows.length,
|
||||
bossObjectives: bosses.length,
|
||||
sourceArtifacts: {
|
||||
recipe: sourceFile(projectPath(recipeFile), recipeFileSha256),
|
||||
snapshot: sourceFile(snapshotPath, snapshotFileSha256, snapshot.contentSha256),
|
||||
mechanicsAudit: sourceFile(mechanicsPath, mechanicsFileSha256),
|
||||
generatedRuntime: sourceFile(projectPath(generatedSourceFile), generatedSourceSha256),
|
||||
upstreamHashes: snapshot.sourceHashes ?? {},
|
||||
},
|
||||
packageAssessment: {
|
||||
status: packageStatus,
|
||||
fidelity: "source-exact",
|
||||
blocker: packageStatus === "intentional-empty"
|
||||
? "The authoritative snapshot contains zero population rows; no combat population is fabricated."
|
||||
: packageStatus === "deferred-source-gap"
|
||||
? "The package is non-empty, but its preserved source classification confirms no boss objectives."
|
||||
: "No package-level population blocker is recorded.",
|
||||
nextWork: packageStatus === "deferred-source-gap"
|
||||
? "Recover authoritative boss membership and encounter order before creating boss objectives."
|
||||
: "Re-run the ledger whenever portable source content changes.",
|
||||
clearingTest: packageStatus === "deferred-source-gap"
|
||||
? "Add a source-backed boss-membership fixture and verify the generated objective list."
|
||||
: "npm run runewaker:audit:gameplay",
|
||||
},
|
||||
deferredRowStatusCounts: statusCounts(deferredSourceRows),
|
||||
evidenceStatusCounts: statusCounts(mechanicsEvidence),
|
||||
evidenceFidelityCounts: fidelityCounts(mechanicsEvidence),
|
||||
deferredSourceRows,
|
||||
mechanicsEvidence,
|
||||
});
|
||||
}
|
||||
|
||||
dungeons.sort((left, right) => compareText(left.id, right.id));
|
||||
const allRows = dungeons.flatMap((dungeon) => dungeon.deferredSourceRows);
|
||||
const allEvidence = dungeons.flatMap((dungeon) => dungeon.mechanicsEvidence);
|
||||
const fallbackBosses = Object.entries(bossMechanics.bosses ?? {})
|
||||
.filter(([, boss]) => boss.source?.kind === "healer-man-source-gap")
|
||||
.map(([entityId, boss]) => {
|
||||
const dungeon = dungeons.find((entry) => entry.id === boss.source.dungeonId);
|
||||
return {
|
||||
entityId,
|
||||
name: boss.name,
|
||||
dungeonId: boss.source.dungeonId,
|
||||
templateId: Number(boss.source.templateId),
|
||||
mechanicIds: (boss.mechanics ?? []).map((mechanic) => mechanic.id).sort(compareText),
|
||||
telegraphedMechanicIds: (boss.mechanics ?? [])
|
||||
.filter((mechanic) => mechanic.telegraph)
|
||||
.map((mechanic) => mechanic.id)
|
||||
.sort(compareText),
|
||||
status: "implemented",
|
||||
fidelity: "healer-man-authored",
|
||||
source: {
|
||||
bossMechanicsCatalog: sourceFile(projectPath(bossMechanicsPath), bossMechanicsFileSha256),
|
||||
snapshot: dungeon?.sourceArtifacts.snapshot ?? null,
|
||||
roots: boss.source.roots ?? [],
|
||||
functions: boss.source.functions ?? [],
|
||||
spellIds: boss.source.spellIds ?? [],
|
||||
},
|
||||
blocker: "Preserved source evidence does not expose an executable original combat kit.",
|
||||
nextWork: "Replace the fallback only after authoritative spell timing, targeting, and effects are recovered; preserve modular IDs and telegraphs.",
|
||||
clearingTest: "Add source-backed mechanic fixtures and assert the catalog source kind changes from healer-man-source-gap to runewaker.",
|
||||
};
|
||||
})
|
||||
.sort((left, right) => compareText(left.entityId, right.entityId));
|
||||
const nonEmptyZeroBossPackages = dungeons
|
||||
.filter((dungeon) => dungeon.sourceRows > 0 && dungeon.bossObjectives === 0)
|
||||
.map((dungeon) => ({
|
||||
dungeonId: dungeon.id,
|
||||
zoneId: dungeon.zoneId,
|
||||
sourceRows: dungeon.sourceRows,
|
||||
status: "deferred-source-gap",
|
||||
fidelity: "source-exact",
|
||||
source: dungeon.sourceArtifacts.snapshot,
|
||||
blocker: "The portable source package contains population rows but no source-confirmed boss membership.",
|
||||
nextWork: "Recover and review authoritative boss membership and objective order; do not promote ordinary templates heuristically.",
|
||||
clearingTest: "Verify at least one source-backed boss objective, or add reviewed evidence that this package intentionally has none.",
|
||||
}));
|
||||
const intentionalEmptyPackages = dungeons
|
||||
.filter((dungeon) => dungeon.sourceRows === 0)
|
||||
.map((dungeon) => ({
|
||||
dungeonId: dungeon.id,
|
||||
zoneId: dungeon.zoneId,
|
||||
sourceRows: 0,
|
||||
status: "intentional-empty",
|
||||
fidelity: "source-exact",
|
||||
source: dungeon.sourceArtifacts.snapshot,
|
||||
blocker: "The authoritative packaged snapshot contains no active population rows.",
|
||||
nextWork: "No population work is scheduled unless a different authoritative source snapshot is supplied.",
|
||||
clearingTest: "Keep the zero-row snapshot hash and intentional-empty classification stable in this audit.",
|
||||
}));
|
||||
const categoryMemberships = {};
|
||||
for (const evidence of allEvidence) {
|
||||
for (const category of evidence.categories) {
|
||||
categoryMemberships[category] = (categoryMemberships[category] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
const luaEvidenceSignals = allEvidence.filter((item) => item.evidenceKind === "lua-line").length;
|
||||
const templateEvidenceSignals = allEvidence.length - luaEvidenceSignals;
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
validationStatus: "green",
|
||||
completionStatus: allRows.length || allEvidence.some((item) => item.status.startsWith("deferred"))
|
||||
? "review-required"
|
||||
: "complete",
|
||||
scope: "All configured offline RuneWaker dungeon packages",
|
||||
generatedBy: "scripts/runewaker-pipeline/audit-encounter-implementation.mjs",
|
||||
deterministic: true,
|
||||
policy: {
|
||||
statuses: IMPLEMENTATION_STATUSES,
|
||||
fidelities: FIDELITY_LEVELS,
|
||||
evidenceRule: "Direct source rows and source references are inventoried once. Regex category memberships may overlap and are never counted as unique mechanics.",
|
||||
failClosedRule: "When static evidence cannot prove complete behavior, the item remains deferred-source-gap rather than receiving a guessed implementation status.",
|
||||
sourceExactRule: "source-exact describes preserved evidence identity and provenance, not an assertion that deferred gameplay behavior is known.",
|
||||
},
|
||||
summary: {
|
||||
dungeons: dungeons.length,
|
||||
deferredSourceRows: allRows.length,
|
||||
uniqueMechanicsEvidence: allEvidence.length,
|
||||
luaEvidenceSignals,
|
||||
templateEvidenceSignals,
|
||||
evidenceCategoryMembershipCount: Object.values(categoryMemberships)
|
||||
.reduce((total, count) => total + count, 0),
|
||||
evidenceCategoryMemberships: Object.fromEntries(
|
||||
Object.entries(categoryMemberships).sort(([left], [right]) => compareText(left, right)),
|
||||
),
|
||||
deferredRowStatuses: statusCounts(allRows),
|
||||
evidenceStatuses: statusCounts(allEvidence),
|
||||
evidenceFidelities: fidelityCounts(allEvidence),
|
||||
verifiedEvidence: allEvidence.filter((item) => item.status === "verified").length,
|
||||
sourceGapEvidence: allEvidence.filter((item) => item.status === "deferred-source-gap").length,
|
||||
fallbackBosses: fallbackBosses.length,
|
||||
nonEmptyZeroBossPackages: nonEmptyZeroBossPackages.length,
|
||||
intentionalEmptyPackages: intentionalEmptyPackages.length,
|
||||
},
|
||||
blockerGroups: {
|
||||
deferredSourceRows: {
|
||||
items: allRows.length,
|
||||
nextWork: "Review source semantics and add explicit source-spawn encounter mappings.",
|
||||
},
|
||||
unresolvedMechanicsEvidence: {
|
||||
items: allEvidence.filter((item) => item.status === "deferred-source-gap").length,
|
||||
nextWork: "Review surrounding Lua/template context; specify full trigger, effect, and reset behavior.",
|
||||
},
|
||||
adaptedSpellFormula: {
|
||||
items: allEvidence.filter((item) => item.status === "verified"
|
||||
&& item.fidelity === "source-backed-adapted").length,
|
||||
nextWork: "Recover native formula inputs before claiming coefficient-level fidelity.",
|
||||
},
|
||||
sourceGapFallbackBosses: {
|
||||
items: fallbackBosses.length,
|
||||
nextWork: "Recover authoritative combat kits and replace only with source-backed modular mechanics.",
|
||||
},
|
||||
nonEmptyZeroBossPackages: {
|
||||
items: nonEmptyZeroBossPackages.length,
|
||||
nextWork: "Recover authoritative boss membership and objective order.",
|
||||
},
|
||||
},
|
||||
specialCases: {
|
||||
fallbackBosses,
|
||||
nonEmptyZeroBossPackages,
|
||||
intentionalEmptyPackages,
|
||||
},
|
||||
issues: [],
|
||||
dungeons,
|
||||
};
|
||||
report.issues = validateReport(report);
|
||||
report.validationStatus = report.issues.length ? "blocked" : "green";
|
||||
return report;
|
||||
}
|
||||
|
||||
export async function generateEncounterImplementationReport() {
|
||||
const report = await buildEncounterImplementationReport();
|
||||
await Promise.all([
|
||||
writeFile(reportJsonFile, JSON.stringify(report, null, 2) + "\n", "utf8"),
|
||||
writeFile(reportMarkdownFile, renderMarkdown(report), "utf8"),
|
||||
]);
|
||||
return {
|
||||
report,
|
||||
reportJson: projectPath(reportJsonFile),
|
||||
reportMarkdown: projectPath(reportMarkdownFile),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { Document, NodeIO } from "@gltf-transform/core";
|
||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||
import { getBounds, mergeDocuments, unpartition } from "@gltf-transform/functions";
|
||||
import { Matrix4, Quaternion, Vector3 } from "three";
|
||||
|
||||
const SUPPORTED_RESOURCE_EXTENSIONS = new Set([".ros"]);
|
||||
const DYNAMIC_HINT_PATTERN = /(?:^|[\\/_-])(door|gate|bridge|drawbridge|lift|elevator|switch|lever|chest|treasure|portal)(?:[\\/_.-]|$)/i;
|
||||
const DYNAMIC_KINDS = new Set([
|
||||
"door",
|
||||
"gate",
|
||||
"bridge",
|
||||
"lift",
|
||||
"switch",
|
||||
"chest",
|
||||
"portal",
|
||||
"other",
|
||||
]);
|
||||
|
||||
export function normalizeWdbResourcePath(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("/", "\\")
|
||||
.replace(/^\\+/, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
export function resourceCacheKey(value) {
|
||||
return createHash("sha256")
|
||||
.update(normalizeWdbResourcePath(value))
|
||||
.digest("hex")
|
||||
.slice(0, 24);
|
||||
}
|
||||
|
||||
function finiteTuple(value, length, name) {
|
||||
if (!Array.isArray(value) || value.length !== length
|
||||
|| value.some((entry) => !Number.isFinite(Number(entry)))) {
|
||||
throw new Error(name + " must contain exactly " + length + " finite numbers.");
|
||||
}
|
||||
return value.map(Number);
|
||||
}
|
||||
|
||||
export function descriptorSourceMatrix(descriptor) {
|
||||
const translation = finiteTuple(descriptor.translation, 3, "descriptor.translation");
|
||||
const rotation = finiteTuple(descriptor.rotation, 4, "descriptor.rotation");
|
||||
const scale = finiteTuple(descriptor.scale, 3, "descriptor.scale");
|
||||
const quaternion = new Quaternion(...rotation);
|
||||
if (quaternion.lengthSq() < 1e-12) throw new Error("descriptor.rotation cannot be zero.");
|
||||
quaternion.normalize();
|
||||
return new Matrix4().compose(
|
||||
new Vector3(...translation),
|
||||
quaternion,
|
||||
new Vector3(...scale),
|
||||
);
|
||||
}
|
||||
|
||||
export function placementMatrixInPrimaryThree(descriptor, primary, metersPerSourceUnit) {
|
||||
const meters = Number(metersPerSourceUnit);
|
||||
if (!Number.isFinite(meters) || meters <= 0) {
|
||||
throw new Error("metersPerSourceUnit must be a positive finite number.");
|
||||
}
|
||||
const sourceToThree = new Matrix4().makeScale(meters, meters, -meters);
|
||||
const threeToSource = sourceToThree.clone().invert();
|
||||
return sourceToThree
|
||||
.clone()
|
||||
.multiply(descriptorSourceMatrix(primary).invert())
|
||||
.multiply(descriptorSourceMatrix(descriptor))
|
||||
.multiply(threeToSource);
|
||||
}
|
||||
|
||||
export function transformSourcePointToPrimaryThree(
|
||||
sourcePoint,
|
||||
descriptor,
|
||||
primary,
|
||||
metersPerSourceUnit,
|
||||
) {
|
||||
const point = new Vector3(...finiteTuple(sourcePoint, 3, "sourcePoint"));
|
||||
point.applyMatrix4(descriptorSourceMatrix(descriptor));
|
||||
point.applyMatrix4(descriptorSourceMatrix(primary).invert());
|
||||
const meters = Number(metersPerSourceUnit);
|
||||
return [point.x * meters, point.y * meters, -point.z * meters];
|
||||
}
|
||||
|
||||
function cloneNodeTree(document, node) {
|
||||
const clone = document.createNode(node.getName())
|
||||
.setMatrix(node.getMatrix())
|
||||
.setExtras({ ...node.getExtras() })
|
||||
.setMesh(node.getMesh())
|
||||
.setCamera(node.getCamera())
|
||||
.setSkin(node.getSkin())
|
||||
.setWeights([...node.getWeights()]);
|
||||
for (const child of node.listChildren()) clone.addChild(cloneNodeTree(document, child));
|
||||
return clone;
|
||||
}
|
||||
|
||||
export async function assembleInstancedGlb(records, outputFile) {
|
||||
if (!records.length) throw new Error("Cannot assemble an empty WDB GLB.");
|
||||
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
|
||||
const target = new Document();
|
||||
const scene = target.createScene("WDB assembly");
|
||||
const recordsByAsset = new Map();
|
||||
for (const record of records) {
|
||||
const rows = recordsByAsset.get(record.assetFile) ?? [];
|
||||
rows.push(record);
|
||||
recordsByAsset.set(record.assetFile, rows);
|
||||
}
|
||||
for (const assetFile of [...recordsByAsset.keys()].sort()) {
|
||||
const source = await io.read(assetFile);
|
||||
const sourceScene = source.getRoot().getDefaultScene() ?? source.getRoot().listScenes()[0];
|
||||
if (!sourceScene) throw new Error("Cached WDB resource has no scene: " + assetFile);
|
||||
const mapping = mergeDocuments(target, source);
|
||||
const mergedScene = mapping.get(sourceScene);
|
||||
if (!mergedScene) throw new Error("Could not merge cached WDB resource: " + assetFile);
|
||||
const roots = mergedScene.listChildren();
|
||||
if (!roots.length) throw new Error("Cached WDB resource has no scene roots: " + assetFile);
|
||||
for (const [index, record] of recordsByAsset.get(assetFile).entries()) {
|
||||
const placement = target.createNode(record.placement.id)
|
||||
.setMatrix(record.placement.transform.primaryLocalThreeMatrix)
|
||||
.setExtras({
|
||||
wdbPlacementId: record.placement.id,
|
||||
wdbGuid: record.placement.guid,
|
||||
sourceResource: record.placement.resource,
|
||||
resourceCacheKey: record.placement.resourceCacheKey,
|
||||
});
|
||||
const nodes = index === 0
|
||||
? roots
|
||||
: roots.map((rootNode) => cloneNodeTree(target, rootNode));
|
||||
for (const rootNode of nodes) placement.addChild(rootNode);
|
||||
scene.addChild(placement);
|
||||
}
|
||||
mergedScene.dispose();
|
||||
}
|
||||
await target.transform(unpartition());
|
||||
const bounds = getBounds(scene);
|
||||
await io.write(outputFile, target);
|
||||
return {
|
||||
bounds: {
|
||||
min: bounds.min.map((value) => Number(value.toFixed(6))),
|
||||
max: bounds.max.map((value) => Number(value.toFixed(6))),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matrixArray(matrix) {
|
||||
return matrix.toArray().map((value) => Number(value.toFixed(10)));
|
||||
}
|
||||
|
||||
function selectPrimaryDescriptor(report, recipe) {
|
||||
const target = normalizeWdbResourcePath(recipe.source.model);
|
||||
const matches = (report.descriptors ?? []).filter((descriptor) => (
|
||||
normalizeWdbResourcePath(descriptor.resource) === target
|
||||
));
|
||||
if (!matches.length) {
|
||||
throw new Error("Expected at least one WDB placement for " + recipe.source.model + ".");
|
||||
}
|
||||
if (matches.length === 1) return matches[0];
|
||||
const expected = recipe.source.primaryPlacementTranslation;
|
||||
if (!Array.isArray(expected) || expected.length !== 3) {
|
||||
throw new Error("Multiple primary-resource placements require source.primaryPlacementTranslation.");
|
||||
}
|
||||
const match = matches.find((descriptor) => descriptor.translation.every((value, index) => (
|
||||
Math.abs(Number(value) - Number(expected[index])) <= 0.0001
|
||||
)));
|
||||
if (!match) throw new Error("Configured primary WDB placement was not found.");
|
||||
return match;
|
||||
}
|
||||
|
||||
function reviewedDynamicRules(recipe) {
|
||||
const rules = recipe.source.wdbAssembly?.dynamicPlacements ?? [];
|
||||
const ids = new Set();
|
||||
return rules.map((rule, index) => {
|
||||
if (!rule || typeof rule !== "object") {
|
||||
throw new Error("source.wdbAssembly.dynamicPlacements[" + index + "] must be an object.");
|
||||
}
|
||||
if (!String(rule.id ?? "").trim()) throw new Error("Dynamic placement rule requires a stable id.");
|
||||
if (ids.has(rule.id)) throw new Error("Duplicate dynamic placement id " + rule.id + ".");
|
||||
ids.add(rule.id);
|
||||
if (!DYNAMIC_KINDS.has(rule.kind)) {
|
||||
throw new Error("Dynamic placement " + rule.id + " has unsupported kind " + rule.kind + ".");
|
||||
}
|
||||
if (!rule.guid && !rule.resource) {
|
||||
throw new Error("Dynamic placement " + rule.id + " requires guid or resource evidence.");
|
||||
}
|
||||
if (!Array.isArray(rule.evidence) || !rule.evidence.length) {
|
||||
throw new Error("Dynamic placement " + rule.id + " requires reviewed evidence.");
|
||||
}
|
||||
return {
|
||||
...rule,
|
||||
normalizedResource: rule.resource ? normalizeWdbResourcePath(rule.resource) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function matchingDynamicRule(descriptor, rules) {
|
||||
const resource = normalizeWdbResourcePath(descriptor.resource);
|
||||
return rules.find((rule) => (
|
||||
(rule.guid && String(rule.guid).toLowerCase() === String(descriptor.guid).toLowerCase())
|
||||
|| (rule.normalizedResource && rule.normalizedResource === resource)
|
||||
));
|
||||
}
|
||||
|
||||
function placementRecord(descriptor, index, primary, recipe, classification, dynamicRule = null) {
|
||||
const resource = normalizeWdbResourcePath(descriptor.resource);
|
||||
const id = classification === "dynamic"
|
||||
? dynamicRule.id + ":" + String(descriptor.guid ?? index)
|
||||
: "wdb:" + String(descriptor.guid ?? index);
|
||||
return {
|
||||
id,
|
||||
guid: descriptor.guid ?? null,
|
||||
resource: String(descriptor.resource).replaceAll("\\", "/"),
|
||||
normalizedResource: resource,
|
||||
resourceCacheKey: resourceCacheKey(resource),
|
||||
classification,
|
||||
transform: {
|
||||
source: {
|
||||
translation: descriptor.translation.map(Number),
|
||||
rotation: descriptor.rotation.map(Number),
|
||||
scale: descriptor.scale.map(Number),
|
||||
},
|
||||
primaryLocalThreeMatrix: matrixArray(placementMatrixInPrimaryThree(
|
||||
descriptor,
|
||||
primary,
|
||||
recipe.coordinateSystem.metersPerSourceUnit,
|
||||
)),
|
||||
},
|
||||
sourceBounds: {
|
||||
local: descriptor.localBounds ?? null,
|
||||
world: descriptor.worldBounds ?? null,
|
||||
},
|
||||
sourceFlags: {
|
||||
zoneFlags: Number(descriptor.zoneFlags ?? 0),
|
||||
detailClass: Number(descriptor.detailClass ?? 0),
|
||||
zoneType: Number(descriptor.zoneType ?? 0),
|
||||
},
|
||||
...(dynamicRule ? {
|
||||
dynamic: {
|
||||
definitionId: dynamicRule.id,
|
||||
kind: dynamicRule.kind,
|
||||
initialState: dynamicRule.initialState ?? "default",
|
||||
collision: dynamicRule.collision ?? "stateful",
|
||||
evidence: dynamicRule.evidence,
|
||||
},
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildWdbPlacementPlan(report, recipe) {
|
||||
if (!report || !Array.isArray(report.descriptors)) {
|
||||
throw new Error("WDB report must contain descriptors.");
|
||||
}
|
||||
const primary = selectPrimaryDescriptor(report, recipe);
|
||||
const dynamicRules = reviewedDynamicRules(recipe);
|
||||
const matchedDynamicRuleIds = new Set();
|
||||
const staticPlacements = [];
|
||||
const dynamicPlacements = [];
|
||||
const reviewRequiredPlacements = [];
|
||||
const unsupportedPlacements = [];
|
||||
const resourceMap = new Map();
|
||||
for (const [index, descriptor] of report.descriptors.entries()) {
|
||||
const resource = normalizeWdbResourcePath(descriptor.resource);
|
||||
const extension = path.extname(resource);
|
||||
if (!SUPPORTED_RESOURCE_EXTENSIONS.has(extension)) {
|
||||
unsupportedPlacements.push({
|
||||
id: "wdb:" + String(descriptor.guid ?? index),
|
||||
guid: descriptor.guid ?? null,
|
||||
resource: descriptor.resource,
|
||||
reason: extension
|
||||
? "unsupported WDB placement resource extension " + extension
|
||||
: "WDB placement resource has no extension",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let matrix;
|
||||
try {
|
||||
matrix = placementMatrixInPrimaryThree(
|
||||
descriptor,
|
||||
primary,
|
||||
recipe.coordinateSystem.metersPerSourceUnit,
|
||||
);
|
||||
} catch (error) {
|
||||
unsupportedPlacements.push({
|
||||
id: "wdb:" + String(descriptor.guid ?? index),
|
||||
guid: descriptor.guid ?? null,
|
||||
resource: descriptor.resource,
|
||||
reason: "invalid WDB transform: " + error.message,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (matrix.elements.some((value) => !Number.isFinite(value))) {
|
||||
unsupportedPlacements.push({
|
||||
id: "wdb:" + String(descriptor.guid ?? index),
|
||||
guid: descriptor.guid ?? null,
|
||||
resource: descriptor.resource,
|
||||
reason: "WDB transform produced a non-finite matrix",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const dynamicRule = matchingDynamicRule(descriptor, dynamicRules);
|
||||
if (descriptor === primary && dynamicRule) {
|
||||
throw new Error("The primary coordinate-anchor placement cannot be dynamic ("
|
||||
+ dynamicRule.id + ").");
|
||||
}
|
||||
const classification = dynamicRule ? "dynamic" : "static";
|
||||
const placement = placementRecord(
|
||||
descriptor,
|
||||
index,
|
||||
primary,
|
||||
recipe,
|
||||
classification,
|
||||
dynamicRule,
|
||||
);
|
||||
if (dynamicRule) {
|
||||
matchedDynamicRuleIds.add(dynamicRule.id);
|
||||
dynamicPlacements.push(placement);
|
||||
} else {
|
||||
staticPlacements.push(placement);
|
||||
if (DYNAMIC_HINT_PATTERN.test(resource)) {
|
||||
reviewRequiredPlacements.push({
|
||||
placementId: placement.id,
|
||||
guid: placement.guid,
|
||||
resource: placement.resource,
|
||||
reason: "Resource name resembles a movable or interactive object, but no reviewed dynamic-placement rule proves its behavior.",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!resourceMap.has(resource)) {
|
||||
resourceMap.set(resource, {
|
||||
resource: placement.resource,
|
||||
normalizedResource: resource,
|
||||
cacheKey: placement.resourceCacheKey,
|
||||
placementCount: 0,
|
||||
staticPlacementCount: 0,
|
||||
dynamicPlacementCount: 0,
|
||||
});
|
||||
}
|
||||
const resourceRecord = resourceMap.get(resource);
|
||||
resourceRecord.placementCount += 1;
|
||||
resourceRecord[classification + "PlacementCount"] += 1;
|
||||
}
|
||||
const unmatchedDynamicRules = dynamicRules
|
||||
.filter((rule) => !matchedDynamicRuleIds.has(rule.id))
|
||||
.map((rule) => ({
|
||||
id: rule.id,
|
||||
guid: rule.guid ?? null,
|
||||
resource: rule.resource ?? null,
|
||||
reason: "Reviewed dynamic-placement selector matched no WDB descriptor.",
|
||||
}));
|
||||
if (unmatchedDynamicRules.length) {
|
||||
throw new Error(unmatchedDynamicRules.map((rule) => rule.reason + " " + rule.id).join("\n"));
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
dungeonId: recipe.slug,
|
||||
sourceWdb: report.sourceWdb,
|
||||
sourceDescriptorCount: report.descriptors.length,
|
||||
primaryPlacement: {
|
||||
guid: primary.guid ?? null,
|
||||
resource: String(primary.resource).replaceAll("\\", "/"),
|
||||
translation: primary.translation.map(Number),
|
||||
rotation: primary.rotation.map(Number),
|
||||
scale: primary.scale.map(Number),
|
||||
},
|
||||
coordinateTransform: {
|
||||
source: recipe.coordinateSystem.source,
|
||||
nativeBridge: recipe.coordinateSystem.nativeBridge,
|
||||
metersPerSourceUnit: Number(recipe.coordinateSystem.metersPerSourceUnit),
|
||||
formula: "threeMatrix=S*inverse(primaryTRS)*placementTRS*inverse(S), S=scale(meters,meters,-meters)",
|
||||
},
|
||||
resources: [...resourceMap.values()].sort((left, right) => (
|
||||
left.normalizedResource.localeCompare(right.normalizedResource)
|
||||
)),
|
||||
staticPlacements: staticPlacements.sort((left, right) => left.id.localeCompare(right.id)),
|
||||
dynamicPlacements: dynamicPlacements.sort((left, right) => left.id.localeCompare(right.id)),
|
||||
reviewRequiredPlacements: reviewRequiredPlacements
|
||||
.sort((left, right) => left.placementId.localeCompare(right.placementId)),
|
||||
unsupportedPlacements: unsupportedPlacements.sort((left, right) => left.id.localeCompare(right.id)),
|
||||
unsupportedResourceTypes: [
|
||||
"WDB placement resources other than ROS",
|
||||
"particle, light, sound, camera, and script-only components not represented as ROS mesh descriptors",
|
||||
"animated or stateful behavior without a reviewed source.wdbAssembly.dynamicPlacements rule",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -694,15 +694,15 @@
|
||||
},
|
||||
"review": {
|
||||
"environment": "authoritative-wdb-largest-model-selected",
|
||||
"collision": "primary-ros-plus-source-anchor-support-review-required",
|
||||
"collision": "full-wdb-static-ros-provisional-review-required",
|
||||
"entrance": "source-script",
|
||||
"bossAnchors": "source-population",
|
||||
"wdbPlacements": "unique-primary-model"
|
||||
"wdbPlacements": "all-static-ros-source-transform"
|
||||
},
|
||||
"exceptions": [
|
||||
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
|
||||
"All valid static WDB ROS placements are assembled; filename-only dynamic candidates remain static until script evidence maps their behavior.",
|
||||
"Collision initially includes decorative primary-ROS geometry.",
|
||||
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work.",
|
||||
"Source-anchor support pads preserve combat and entrance coordinates where modular WDB sections or primary-ROS collision leave navmesh gaps; full secondary placement assembly remains a visual follow-up."
|
||||
"The full WDB static assembly replaces the former source-anchor support pads; entrance and combat anchors still require live collision/navigation review."
|
||||
]
|
||||
}
|
||||
@@ -1059,20 +1059,22 @@
|
||||
"agentRadius": 0.36,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"tileSize": 64,
|
||||
"tileSize": 512,
|
||||
"partition": "monotone",
|
||||
"forceTiled": true,
|
||||
"retainTileIntermediates": false
|
||||
},
|
||||
"review": {
|
||||
"environment": "authoritative-wdb-largest-model-selected",
|
||||
"collision": "primary-ros-plus-source-anchor-support-review-required",
|
||||
"collision": "full-wdb-static-ros-provisional-review-required",
|
||||
"entrance": "source-script",
|
||||
"bossAnchors": "source-population",
|
||||
"wdbPlacements": "unique-primary-model"
|
||||
"wdbPlacements": "all-static-ros-source-transform"
|
||||
},
|
||||
"exceptions": [
|
||||
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
|
||||
"All valid static WDB ROS placements are assembled; filename-only dynamic candidates remain static until script evidence maps their behavior.",
|
||||
"Collision initially includes decorative primary-ROS geometry.",
|
||||
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work.",
|
||||
"Source-anchor support pads preserve combat and entrance coordinates where modular WDB sections or primary-ROS collision leave navmesh gaps; full secondary placement assembly remains a visual follow-up."
|
||||
"The full static WDB collision requires a tiled monotone Recast build because the solo watershed contour triangulator rejects this source geometry."
|
||||
]
|
||||
}
|
||||
@@ -2719,20 +2719,22 @@
|
||||
"agentRadius": 0.36,
|
||||
"agentMaxClimb": 0.5,
|
||||
"agentMaxSlope": 50,
|
||||
"tileSize": 64,
|
||||
"tileSize": 512,
|
||||
"partition": "monotone",
|
||||
"forceTiled": true,
|
||||
"retainTileIntermediates": false
|
||||
},
|
||||
"review": {
|
||||
"environment": "authoritative-wdb-largest-model-selected",
|
||||
"collision": "primary-ros-plus-source-anchor-support-review-required",
|
||||
"collision": "full-wdb-static-ros-provisional-review-required",
|
||||
"entrance": "source-script",
|
||||
"bossAnchors": "source-population",
|
||||
"wdbPlacements": "unique-primary-model"
|
||||
"wdbPlacements": "all-static-ros-source-transform"
|
||||
},
|
||||
"exceptions": [
|
||||
"Secondary WDB doodads/effects remain in the audit; this pass exports the primary ROS hierarchy.",
|
||||
"All valid static WDB ROS placements are assembled; filename-only dynamic candidates remain static until script evidence maps their behavior.",
|
||||
"Collision initially includes decorative primary-ROS geometry.",
|
||||
"Original GR2 actor animations are recovered where source graphs and validation permit; scripted encounter mechanics remain evidence-driven per-instance work.",
|
||||
"Source-anchor support pads preserve combat and entrance coordinates where modular WDB sections or primary-ROS collision leave navmesh gaps; full secondary placement assembly remains a visual follow-up."
|
||||
"The full WDB static assembly replaces the former source-anchor support pads; source-backed switch and drawbridge behavior is represented by encounter world objects pending original object-mesh animation mapping."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const catalogFile = path.join(
|
||||
projectRoot,
|
||||
"scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
|
||||
);
|
||||
|
||||
test("every executable RuneWaker damage family carries source-shaped formula evidence", async () => {
|
||||
const catalog = JSON.parse(await readFile(catalogFile, "utf8"));
|
||||
const executable = catalog.spells.filter((spell) => spell.mappingStatus === "damage-family");
|
||||
assert.equal(executable.length, catalog.damageFamilyCount);
|
||||
for (const spell of executable) {
|
||||
const primary = spell.components.find((component) => component.directHpDamage);
|
||||
assert.ok(primary, `spell ${spell.spellId} has no primary damage component`);
|
||||
assert.equal(spell.runtime.formula.spellId, spell.spellId);
|
||||
assert.equal(spell.runtime.formula.componentId, primary.id);
|
||||
assert.equal(spell.runtime.formula.damagePower, primary.damagePower);
|
||||
assert.equal(spell.runtime.formula.fixedValue, primary.fixedValue);
|
||||
assert.equal(spell.runtime.formula.dotBase, primary.dotBase);
|
||||
assert.equal(spell.runtime.formula.provenance.physicalFunctionLine, 3366);
|
||||
assert.equal(spell.runtime.formula.provenance.magicFunctionLine, 3651);
|
||||
if (spell.runtime.formula.powerKind === "source-ratio") {
|
||||
assert.ok(Math.abs(primary.damagePower) <= 4);
|
||||
assert.equal(spell.runtime.damageMultiplier, spell.runtime.formula.runtimeBaselineMultiplier);
|
||||
}
|
||||
}
|
||||
assert.equal(catalog.safety.unavailableNativeInputsDefaulted, false);
|
||||
assert.equal(catalog.safety.pointValuedDamageUsesLabeledScaledAdapter, true);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const reportFile = path.join(projectRoot, "scripts/runewaker-pipeline/RUNEWAKER_LOOT_IMPORT_REPORT.json");
|
||||
const catalogFile = path.join(projectRoot, "src/game/generated/runewakerLootCatalog.json");
|
||||
|
||||
async function json(file) {
|
||||
return JSON.parse(await readFile(file, "utf8"));
|
||||
}
|
||||
|
||||
test("RuneWaker loot catalog covers every source-classified boss", async () => {
|
||||
const [catalog, recipeNames] = await Promise.all([
|
||||
json(catalogFile),
|
||||
readdir(path.join(projectRoot, "scripts/runewaker-pipeline/recipes")),
|
||||
]);
|
||||
let expectedBosses = 0;
|
||||
for (const name of recipeNames.filter((candidate) => candidate.endsWith("-population.json"))) {
|
||||
const recipe = await json(path.join(projectRoot, "scripts/runewaker-pipeline/recipes", name));
|
||||
expectedBosses += recipe.templates.filter((template) => template.classification === "boss").length;
|
||||
}
|
||||
assert.equal(catalog.bossCount, expectedBosses);
|
||||
assert.equal(catalog.bosses.length, expectedBosses);
|
||||
assert.equal(new Set(catalog.bosses.map((boss) => boss.bossKey)).size, expectedBosses);
|
||||
});
|
||||
|
||||
test("source probabilities and fail-closed outcomes are internally valid", async () => {
|
||||
const catalog = await json(catalogFile);
|
||||
for (const boss of catalog.bosses) {
|
||||
assert.ok(["portable", "partial", "no-source-drops", "blocked"].includes(boss.status));
|
||||
for (const roll of boss.rolls) {
|
||||
assert.ok(roll.chancePerKill > 0 && roll.chancePerKill <= 1);
|
||||
const total = roll.outcomes.reduce((sum, outcome) => sum + outcome.conditionalChance, 0);
|
||||
assert.ok(total <= 1.000000001, `${boss.bossKey} slot ${roll.sourceSlot} exceeds probability 1`);
|
||||
for (const outcome of roll.outcomes) {
|
||||
assert.ok(outcome.conditionalChance > 0 && outcome.conditionalChance <= 1);
|
||||
if (outcome.runtimeStatus === "blocked") assert.ok(outcome.blockedReason);
|
||||
else assert.ok(outcome.sourceCatalog);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("the compact report agrees with the full catalog and pins every source hash", async () => {
|
||||
const [catalog, report] = await Promise.all([json(catalogFile), json(reportFile)]);
|
||||
for (const key of [
|
||||
"bossCount",
|
||||
"bossWithSourceRollsCount",
|
||||
"portableBossCount",
|
||||
"partialBossCount",
|
||||
"noSourceDropsBossCount",
|
||||
"blockedBossCount",
|
||||
"executableOutcomeCount",
|
||||
"blockedOutcomeCount",
|
||||
]) assert.equal(report[key], catalog[key]);
|
||||
for (const source of report.generatedFrom.sourceFiles) {
|
||||
const bytes = await readFile(path.resolve(projectRoot, source.file));
|
||||
assert.equal(createHash("sha256").update(bytes).digest("hex"), source.sha256);
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user