Update Healer Man server

This commit is contained in:
phenom committed 2026-10-04 13:13:35 -04:00
1 parent 39458d5de6
commit 6cc1b9217f
203 files changed
+133929 -31271

No files matched your search

+286
View File
@@ -4,8 +4,238 @@ This file is the durable record of runtime behavior, regressions, and the
evidence required before calling a visual bug fixed. Update it whenever scene,
camera, input, physics, mob-rendering, or asset-loading code changes.
## 2026-10-04 Timed mechanics and campaign bosses
- Shared Killing Spree now schedules five player/companion assaults over two
seconds, including the application pulse. Companion AI selects the learned
action; actual scene relocation and owner-local cleanup have focused coverage.
This supersedes the immediate-player/NPC-gap limitation in the older entry.
Child weapon amounts, seeded target choice and teleport geometry retain the
documented simulator approximations.
- The exact five native builds were audited. Knight/Scout Blood Punishment now
grants +15% received healing and +5% Holy damage on Blood Arrow. Actual NPC
casts use combat effect schools and consume outgoing aura modifiers once on
their respective native/compatibility paths. The separate current-CoA Blood
Arrow base-toggle/cost migration remains open; no loot or cap change.
- The player and spectator follow cameras share physical terrain and visible
wall/pillar obstruction queries. Final-build Wailing screenshots near the
formerly clipped pose show loaded character bodies and equipped silhouettes,
right-drag rotation, and a stable released view. The old foreground obstruction
is absent at this nearby sample. Other cave viewpoints remain partially framed
by rock; this does not certify universal camera visibility.
- Normal final-build Barren Caves clears Anglo 1/1 in 13:36.9. The rendered FULL
DUNGEON CLEAR panel agrees with dead/zero-HP boss telemetry. The initially
level-30 Priest/Knight naturally reaches 36; Rorik dies and the other four
actors survive. Effective healing totals 46,328.47. Living-party separation
peaks at 12.52 m in periodic route samples and 15.56 m after packs. The native
overlay stops further input; no loot action is taken. Evidence:
`playtest-artifacts/campaign-boss-pass/final-build-20261004-150021873/`.
- Strict Wailing movement reaches Lady's anchor without the earlier fall:
433.96 m traveled, all 284 movement vertices checked and five actors surviving.
Lady fights and is defeated during pack stabilization before the endpoint;
actual source Bolt/player damage events and captured "already defeated"
feedback establish that result. The old endpoint-only classifier misses this
earlier fight after the boss leaves active state. Full Wailing clear and its
remaining seven bosses/Naralex event are unverified. Effective healing totals
28,587.81. Empty guard-attempt records are not guard-clear proof. Evidence:
`playtest-artifacts/campaign-boss-pass/final-build-20261004-151549020/`.
- At both sampled Wailing camera probes, held right-drag changes yaw by -0.378
radians; pointer lock stays null and released/unheld movement changes yaw by
zero. Player displacement during either drag is below 0.001 m. The early
probe is 5.18 m from the old clipped pose, so the screenshots establish nearby
visual improvement, not an exact-pose boom measurement.
- Ordinary final-build Boss Testing confirms Vow cancels a real source Lightning
Bolt 9532 and applies the 8,400-ms Silence status to source creature 3671,
without staging enemy casts or editing live combat state. The aura cancels the
cast before the explicit interrupt effect, so no Nature school lock is set.
Source immunities are Fear/Horror only. A longer observation then reveals
Nature Thorns casts starting during silence, requiring a separate cast-gate
correction; that pre-correction run establishes initial cancellation only.
Evidence: `playtest-artifacts/vow-live-final/final-build-20261004-151736260/`.
- Production build and 266 focused assertions across eleven files pass, plus
Ready-five's seven game/twenty Python tests. Route assets are
`index-DHQInfBp.js`, `combatStore-Cx06rFzG.js`, `GameRuntime-D87YWi4Q.js`,
`PartyFrames-5jlxKxxK.js`, `romAbilityCatalog-CHjYfHkT.js`, and
`index-499eecpe.css`. Browser results on later assets must be labeled separately.
- The corrected final-build Vow replay passes full suppression: the real Bolt
is canceled before release, no nonphysical cast is observed during the actual
8,400-ms Silence status, and source Nature Bolt resumes naturally 61 ms after
expiry. Lady is defeated with all five actors alive, no XP/loot and no readiness
change. Pre/post cast and boss-defeated images were visually inspected; bodies
and equipped silhouettes render. No cast-cancel presentation event is claimed.
Evidence: `playtest-artifacts/vow-live-final/final-build-20261004-153859692/`.
Corrected assets: `index-Cm7Olrd2.js`, `combatStore-1B4KOVhS.js`,
`GameRuntime-hM1AaOYR.js`, `PartyFrames-BfZEkXH5.js`,
`romAbilityCatalog-DjRUCJbs.js`, and `index-499eecpe.css`. The preceding campaign
runs retain their recorded earlier assets. Final build and 30 additional
cast/Vow/source assertions pass (296 focused total across fourteen files);
Ready-five's seven game/twenty Python tests are rechecked after the correction.
See `docs/MECHANIC_PARITY_CAMPAIGN_PASS_2026-10-04.md` and its scoped reports for
source limits, normal-campaign outcomes and the independent live Vow evidence.
## 2026-10-04 First CoA party kickoff diagnostic
- The selected party is Guardian/Vanguard, Witch Doctor/Brewing,
Barbarian/Brutality, Ranger/Archery, and Pyromancer/Incineration. Offline
preparation fixes those five build identities and writes a source/mechanic
inventory plus five explicit active-player configurations.
- Exposed 124 sourced active Talent actions across these five classes, retaining
their learned-rank gates and passive exclusions. Verified Potion Toss's base
heal and Mana payment on a player and companion, Decapitate's strictly below
35% target-health gate, and Ranger primary Focus spending/recovery. Ingredient
riders, execute Energy conversion, and full class state remain pending.
- Automated targeted runs use level 60, seed 1, and Taragaman the Hungerer only,
capped at 30 seconds. Guardian/Witch Doctor/Ranger/Pyromancer active seats
reach the time limit with 80,065/79,292/12,782/14,724 boss health remaining;
Barbarian's active seat defeats the boss in 12.6 seconds. All five runs record
zero deaths. Raw evidence is under `artifacts/coa-first-party/level-60/`.
- Companion casts remain sparse and player-seat results differ substantially.
The healer-seat Ranger companion casts three times and records 32.3% Focus
starvation after its primary-pool correction. This baseline identifies work;
it does not establish class correctness, tank/healer pressure coverage, or
balance. No live browser/visual playtest was performed in this kickoff.
- Validation: 87 focused game tests across 15 files; Ready-five seven game and
twenty Python tests; production TypeScript/build/asset audits/compression and
bundle verification pass. Readiness and ordinary Ready-only pools are unchanged.
See `docs/COA_FIRST_PARTY_MILESTONE.md` for the source anchors, evidence gaps,
repeatable commands, and completion gate.
## 2026-10-04 Interrupt, sustain and campaign follow-up
- Correct the pinned AzerothCore creature-immunity importer: `CreatureImmunitiesId`
is a foreign key. Lady Anacondra's row -93 supplies Fear/Horror only; the
source-registered Lightning Bolt regression now cancels under Vow of Silence.
True Silence/Interrupt immunity remains protected. This supersedes the source
interpretation of the older failed browser attempt below; that full fight was
not rerun after the correction.
- The native Knight player plus owned Priest/Rogue/Scout/Mage party clears three
consecutive Anglo pulls at levels 60 and 80, preserving resources, effects
and cooldowns through 10-second/90-second rests. Six clears, zero deaths, no
empty Priest Mana pools. Delivered overheal is 63.9%/30.6%; direct heals start
on injured snapshots even with Regenerate active. Recovery and healing AI are
unchanged. Actual owned-NPC telemetry is separate from player pressure data.
- Killing Spree's representative signature now checks its source class-script
selector, while preserving the ten-strike player regression. The linked child
61851 damage aura is corrected from 1% to 20% for two seconds. Companions still
lack this script; immediate player assaults and deterministic selection remain
approximations. This supersedes the earlier 30/31 test result below.
- The floating Roster details toggle intercepted Continue at 1440x900. The
single-screen roster now reserves footer space. Normal pointer hit checks and
screenshots show clearance at 1440x900 and 390x844; body/staff remain visible.
Other compact layout details and dual-display layouts are not certified here.
- Original campaign entry plus 90-second entrance/encounter segments are checked
independently. Barren records 69.34 accumulated travel units, 41 keyboard
steps, 15 effective player heals (~6,155), seven player damage events (549),
and no deaths; earned XP naturally raises the player from 30 to 31. Its valid
phase is in `playtest-artifacts/native-routes/final-20261004-051413627/`.
- Corrected Wailing records 23.76 travel units, 15 keyboard steps, 11 effective
heals (~6,831), three observed entrance enemy deaths and no party deaths.
Player DPS is zero and remains unverified there. Normal Attack/Defend inputs
keep the entrance fight near the healer. Evidence:
`playtest-artifacts/native-routes/final-20261004-052623521/`.
- Preserve the first longer Wailing trace in `final-20261004-051737171/`: the
tank died after repeated out-of-range heals while automated steering fell
behind. The harness now follows actual companion positions, prefers a bound
ranged attack and waits for casts. Game balance/recovery are unchanged.
Final phases have no page errors; the logged 404 URL is favicon.ico.
- Campaign midpoint/endpoint images were visually inspected. Midpoints show
Urgent Heal on an injured native tank and loaded bodies/equipment. Endpoint
views are partially occluded by cave geometry. Full dungeon completion,
route-guide accuracy, camera visibility and complete mobile QA remain unproven.
- Final production build passes TypeScript, asset audits, bundling, compression
and bundle verification. Chunks: `index-CAMDJqEC.js`,
`combatStore-t7bO4yKt.js`, `GameRuntime-otPnJCWI.js`,
`PartyFrames-BsBsesPR.js`, and `index-499eecpe.css` with the roster clearance.
Focused checks pass 40 interrupt/enemy cases, five importer cases, 81
Rogue/catalog/rotation cases and the six-pull sustain diagnostic; the final
Vow-only rerun passes eight. Ready-five remains seven Vitest/twenty Python.
See `docs/ROM_INTERRUPT_SUSTAIN_ROUTE_FOLLOWUP_2026-10-04.md` and its linked
source, sustain, Rogue and campaign reports for attribution and scope limits.
## 2026-10-03 Selectable RoM native stats
### Source recovery, equipment completion and full selected fights
- The final build passes app TypeScript, production bundling, asset audits,
compression and bundle verification. Exact chunks: `index-DzLgXnx0.js`,
`combatStore-Dh-12M9R.js`, `GameRuntime-C95Du8PI.js`,
`PartyFrames-Cf7FSiBA.js`. Source/recovery/resource checks pass 156 tests;
native pacing passes five; legacy pacing passes 18 with all 83 timing samples;
the unchanged 80-pull native cohort has zero deaths/invalid casts.
- Native player/owned-NPC HP and Mana now use source ReHP/ReMP pulses, retaining
per-actor compatibility choices. All 440 imported NPC equipment defaults and
11,000 candidate pairs resolve supported snapshots. Extra off-hand/bow swings
are not scheduled. Source recovery required early native boss calibration:
level-one boss factor 0.30 becomes 0.20, with trash/later anchors unchanged.
Holy Strike keeps its Holy amount while starting physical auto-attacks; early
native Priest healers reserve one learned direct heal before hostile spending.
- `--full-fights` creates a native level-30 Priest/Knight healer and the other
four native identities, then completes original-cooldown Boss Testing scenes
on this exact build. Anglo clears in 39.9s, Lady Anacondra in 16.5s; all five
actors survive and there are no page errors. Player Mana ends at 88.9%/91.0%.
These testing-room health pools are separate from campaign durability.
- Anglo Physical Trample/Bash cancellations are observed before completion
alongside resolved healer counter inputs. Gulp clears from the tank 1.85s
before expiry after a resolved cleanse; a Rogue's separate Gulp expires
naturally. Lady retains Nature-school Lightning Bolt; her Mage's Slumber
clears 4.74s before expiry after cleanse. A single Vow of Silence attempt does
not cancel Lightning Bolt, so that particular cross-source counter is unproven.
- Full fights record zero movement inputs. Separate labeled same-build fixtures
retain Trample's source 1.8s warning/radius five/effect. Real W movement carries
the player 2.75 units from distance 3.93 to 6.69 while the cast stays live:
player damage is zero, nearby Rogue damage 262. Trample, Bash and Gulp each
release once; source attack selection/readiness, repeat cooldown, positions,
threat and companion pause are fixture setup.
- Roster body/staff, loaded player/party bodies and held weapon/shield/staff/bow
silhouettes at mid-fight, native recovery/Training gear UI, defeated-boss
results and Trample warning/movement images were visually inspected. Early
NPC loading placeholders resolve by mid-fight. Evidence:
`playtest-artifacts/native-combat/full-fights-20261004-024033763/` and the
same-build fixture `playtest-artifacts/native-combat/results.json`.
- Ready-five passes seven Vitest/twenty Python cases; avatar checks pass 47.
An additional shared rotation selection passes 30/31, with the existing
unsupported Rogue Killing Spree signature check still failing. High-level
native healer Mana remains comfortable. No all-class sweep, loot wiring,
cap change, readiness submission or deployment. This is selected desktop
scene evidence, not full navigation/mobile/every-encounter certification.
See `docs/ROM_NATIVE_RECOVERY_EQUIPMENT_PACING_PASS_2026-10-03.md` for source
ownership, measured pressure, test boundaries and pre/post evidence.
### Five-class scaling follow-up
- The subsequent final build passed with `index-Ba_szGct.js`,
`combatStore-BGky6YlB.js`, and `PartyFrames-CcpsvWIi.js`. Exact-dist Edge QA
passed after native NPC level/cap reconciliation and Training gear label fixes.
`scripts/playtest-stat-rulesets.cjs` used an isolated temporary account/server.
- RoM Equipment slots and fallback names display Training gear, with the
automatic level-79 cap note. Native player tabs and Durnik's native Mage NPC
Attributes panel passed; desktop and 390-pixel layouts remained readable.
Legacy profiles, independent stat choices, switching locks, build/checkpoint
preservation, and both native/compatibility dungeon runs passed again.
- Roster and both dungeon captures visibly show character bodies and held starter
staffs. Dungeon overlays and loading intro were closed for those captures.
Native player/NPC HP and MP displays remained whole and readable.
- No page errors, HTTP failures, or failed application-resource responses
occurred; the only console response error was the optional favicon 404.
- Latest evidence supersedes the shared artifact path below:
`playtest-artifacts/stat-rulesets/results.json` and its eleven screenshots,
including `05-native-npc-stats-attributes-desktop.png`,
`04-native-dungeon-gameplay-overlays-closed.png`,
`08-wow-compatibility-dungeon-gameplay-overlays-closed.png`, and
`07-native-stats-attributes-mobile.png`.
- Final focused validation passed 176 tests across twelve files; Ready-five
passed seven Vitest and twenty Python checks. Avatar validation passed 47 tests
before the subsequent party-only fixes. The targeted Barren Caves diagnostic
recorded uneven pacing, including slow low-level bosses and brief high-level
trash; no enemy HP, loot, or readiness policy was changed. Full diagnostic and
remaining boundaries: `docs/ROM_FIVE_NATIVE_SCALING_2026-10-03.md`.
### Original stat-ruleset implementation
- `scripts/playtest-stat-rulesets.cjs` passed against the final production build
in Edge with an isolated temporary offline account/server. Checked chunks:
`index-CGkT8hBz.js`, `combatStore-CVXgxk1Z.js`, and
@@ -4295,3 +4525,59 @@ Vite's existing large-chunk advisory remains informational.
- This is a representative catalog/UI smoke check. It does not exercise an
unlocked Shadowhound, the other CoA classes' combat mechanics, or balance.
All CoA specializations remain Needs Testing.
## 2026-10-03 — Native RoM five combat pass
- Added native source amount dispatch for direct Priest healing and supported direct attacks, including separate periodic HolyHeal snapshots. Player/NPC Heal, Urgent Heal, Regenerate, target absorption, aura-adjusted Wisdom/MDMG/HealPower, and WoW fallback regressions pass. Native training weapon amounts/speeds feed physical auto attacks; existing semantic haste and race-affix timing remain common simulator stages.
- The targeted full-player/four-NPC diagnostic covers the exact five identities at levels 1/10/30/60/65/66/79/80 against Barren Caves trash and Anglo. All 80 pulls clear with zero deaths or invalid casts. Native-mode health budgets produce trash kills in 4.5–9 seconds and Anglo kills in 39–53.4 seconds. Static placement omits navigation and encounter scripts; this is not balance certification. The legacy WoW-stat RoM curve and enemy damage are preserved.
- The focused 15-file validation passed 210 tests before the extra Reflected Shot consumer-fallback regression; that regression and the five-build amount parity test both pass. Ready-five validation passed 7 Vitest and 20 Python checks; all 47 avatar/equipment checks passed. The final full build, asset audits, compression, and release-bundle verification passed.
- The final native damage selection passed 13 tests: seven source-helper, two amount/parity/fallback, and four actual combat-event integration checks. Player Knight main-hand, NPC Scout ranged, NPC Rogue off-hand, and player Scout Shot use their verified source channels; the auto-event checks also verify semantic haste timing. The final project TypeScript check passed.
- The final production browser smoke used `index-D_h9IRuA.js` and `combatStore-BiA9BOQG.js`, isolated server/account data, and desktop plus 390x844 viewports. Native player and actual party-owner stats, automatic training labels, legacy save migration, build/gear/checkpoint preservation, combat/activity switching guards, and switching back to WoW stats passed. Ordinary live generated RoM companions all belonged to the active five. The first browser attempt exposed a QA-only hardcoded Durnik selection assumption; the script now inspects an actual native party member and the final run passes.
- Visually reviewed `playtest-artifacts/stat-rulesets/01-roster-native-rom.png`, `04-native-dungeon-gameplay-overlays-closed.png`, `05-native-npc-stats-attributes-desktop.png`, and `07-native-stats-attributes-mobile.png`. Roster and dungeon bodies remain visible with starter staff, shield, and sword silhouettes; baseline RoM clothing/underwear appearance remains unchanged. Native player/NPC attributes and training labels are readable, and the narrow panel scrolls inside the viewport. Results and 11 representative screenshots are in `playtest-artifacts/stat-rulesets/results.json`.
- No browser page errors, server errors, or failed HTTP requests were recorded. The only console error was the existing optional favicon 404. The separate `combatRotation.test.ts` has 30/31 passing assertions; the existing unsupported-only Combat Rogue Killing Spree signature assertion remains outside this RoM pass.
- At this earlier checkpoint, source target share/ordinal falloff, throw/prior-hit/shield-category damage inputs, unverified custom weapon profiles, and native defense/hit/critical channels still had explicit simulator boundaries. The follow-up below resolves the supported Throw, shield, multi-target, defense, hit, and critical paths; prior-hit inputs and unsupported/custom gear remain explicit boundaries. Reflected Shot stays on the common amount fallback until actual target enumeration reaches the native adapter. Public add-on/CoA research informed clean-room rotation priorities; a Cloudflare challenge blocked the KittyCombo archive download, so no add-on code or preset was imported.
- Level cap remains 80. No loot wiring, deployment, commits, or readiness promotion. See `docs/ROM_NATIVE_COMBAT_PASS_2026-10-03.md` for formulas and remaining source boundaries.
## 2026-10-03 — Native enemy equipment, manual packets and pressure
- The equipment/formula/encounter follow-up is documented in `docs/ROM_NATIVE_ENEMY_ENCOUNTER_PASS_2026-10-03.md`. Native source equipment supports 398/440 imported NPC snapshots under default deterministic selection; 42 unsupported snapshots retain declared fallback. Exact bound fixed/MaxHP enemy packets now use source absorption and target ordering without generic DEF/resistance/crit. Unbound Throw/post-table absorption remains unsupported. No loot is wired and cap 80 remains.
- The new native shared-damage budget interpolates 1:1, 10:1, 30:3, 60:10, 65:12, 66:20, 79/80:40. Exact manual source packets bypass this curve. Native health anchors, compatibility scaling and difficulty sliders remain intact. Live mob registration supplies the budget and passes repeat-registration/mode-switch regressions.
- Six headless encounter/pressure checks cover actual Anglo Trample/Bash warning-time movement, Gulp targeting/manual dispels, a real companion interrupt and a real NPC Priest cleanse. Self-Regenerate now enters the healing ledger; interrupt/control cancellation clears cast presentation. Sixteen pressure pulls separate enemy damage from native ability health costs and all clear with no deaths/invalid controller casts. Shared mana sustain is still an open balance limitation.
- The regenerated 80-pull active-five diagnostic clears all pulls with no player/NPC deaths or invalid casts. Trash takes 4.9–8.7 seconds and Anglo 42.0–58.5 seconds. Focused source/native checks pass 141 distinct tests across 22 files, including 40 actual player/NPC fixed/MaxHP packet cases. Ready-five passes seven Vitest and twenty Python checks; avatar checks pass 47. A separate legacy scaler suite passes 13 checks and fails four compatibility-pacing expectations. The committed-scaler replay reproduces the two WoW starter failures and RoM Mage level-eight failure; the exact level-80 healer case was not replayed. Current/committed scaler outputs match all four original inputs. Compatibility pacing is not retuned.
- The final production build passes bootstrap/assets, TypeScript/Vite, compression and release-bundle verification. Exact chunks are `index-pDR2PGJY.js`, `combatStore-7m63VWbc.js` and `GameRuntime-CGZMQPoV.js`. Actual level-30 Anglo/Lady Boss Testing snapshots record native budget 3 (attack damage 289/354), confirming live population passthrough.
- Final Edge fixtures pass with zero page errors and preserve source cast times/ranges/effects/telegraphs. Trample movement avoids player damage; Bash releases once at source range; Gulp controls a nearby Rogue and expires naturally. This browser sample does not prove NPC cleansing; the headless test separately does. The fixture-only final run reuses successful normal-scene snapshots from the same build (`normalScenesReran: false`); it does not certify a normal full clear or navigation. See `playtest-artifacts/native-combat/results.json`, normal sample JSON and screenshots, plus six `anglo-*-fixture.png` captures. Roster, party/dungeon and selected mechanic screenshots were visually inspected and retain bodies and starter shield/sword/staff silhouettes. No readiness/report submission occurs.
## 2026-10-03 — Native RoM combat resolution and enemy damage types
The equipment/manual-formula, damage-pressure and production QA evidence above supersedes this earlier pass's corresponding limits and measurements; its native health anchors remain current.
- The supported native path now resolves defense, hit/avoidance, criticals, periodic damage, special Throw and shield inputs, and multi-target damage for the five canonical active builds with canonical training gear. Player and companion actors keep independent rulesets; incoming resolution uses the recipient's own secondary class, gender, and ruleset, so a WoW-mode recipient keeps the compatibility resolver when another actor uses native stats. Native points are never converted into WoW ratings. Exact RuneWaker NPC identity travels independently from WoW creature/server entry IDs. Missing NPCs, unsupported source calculations, ambiguous source equipment, and custom/unverified gear retain an explicit fallback.
- The enemy classifier keeps WoW and RuneWaker spell ID namespaces separate. WoW packets retain DBC `DmgClass` and full `SchoolMask`; RuneWaker's six source resistance channels stay distinct even when simulator aliases share a school. Direct and periodic parts of one source cast share their per-target application hit gate. Periodic ticks reuse application snapshots without new hit, critical, armor, or resistance rolls; HP-percent ticks use the recipient's current maximum health. Enemy periodic effects have their own scheduler and are cleared by dispels and encounter resets. Multi-target source sharing and ordinal falloff are applied once.
- A shared hit-gate regression for unsupported NPC `CalcType` 4 packets was fixed in runtime and covered by a regression test.
- `artifacts/rom-five-native-rotation/report.json` is the final native pacing sample: 80/80 clears, zero invalid casts, zero player or companion deaths, and source-supported trash and Anglo in every sample. Trash took 4.9–8.7 seconds and Anglo 40.5–58.5 seconds. The five identities rotate through the player seat at levels 1/10/30/60/65/66/79/80, with four native companions and fixed actor positions. This diagnostic does not cover movement or authored encounter mechanics and is not readiness certification.
- After the shared-gate fix, focused validation passed 140 tests across 18 files, and the native HP/pacing selection passed 7/7. The 80-sample report was regenerated with the same pacing, maximum live enemy level 80, source support for all samples, zero deaths, and zero invalid casts.
- Final native RoM health factors are [level, trash, boss]: [1, 0.4, 0.3], [10, 1.0, 0.6], [30, 1.1, 0.85], [60, 1.5, 1.5], [65, 1.8, 1.9], [66, 2.6, 2.8], [79, 4.5, 4.8], and [80, 4.5, 4.8], with linear interpolation between anchors. These are simulator training-party pacing budgets, not imported monster-health formulas. The player level cap remains 80; no new loot wiring, matchmaking, or readiness promotion is included. Existing saved WoW-stat mode and per-actor rulesets remain supported.
- Ready-five (7 Vitest and 20 Python checks) and all 47 avatar/equipment checks were also run during this follow-up. The final TypeScript/Vite build, bootstrap/asset audits, compression and release-bundle verification passed; six shared-gate/periodic checks passed after the final result-field cleanup.
- Final production browser QA uses `index-DyCSc4Cz.js` and `combatStore-nQykOmDD.js`. Stat-ruleset checks pass on desktop and 390×844 mobile, covering native/compatibility mode, saves, guards and each actor's own panel. Isolated Boss Testing observes Anglo's Gulp control effect and Lady Anacondra's Nature-school Lightning Bolt against the native party, with no page errors and no readiness/report submission. Results are in `playtest-artifacts/stat-rulesets/results.json` and `playtest-artifacts/native-combat/results.json`.
- Visually reviewed the roster, normal native dungeon, native NPC stats, mobile equipment and both selected boss-mechanic screenshots. Character bodies and starter staff, shield and weapon silhouettes remain visible; stat labels are readable and the narrow panel scrolls inside the viewport. The targeted scenes do not certify every mechanic or dungeon. See `docs/ROM_NATIVE_COMBAT_RESOLUTION_PASS_2026-10-03.md` for source boundaries and formulas.
## Desktop performance check (2026-10-04)
- Production headless Edge on the Radeon RX 9070 XT exercised Wailing Caverns entry combat, movement, pause/resume, synthetic visibility changes, and three entry/exit cycles. Evidence is in `artifacts/playtest/performance/before/`, `after/`, and `confirmation/`; the reusable route is `scripts/playtest-performance.cjs`.
- Fixed repeated full-catalog scan/sort and pack-receipt checks with an immutable manifest index and publication-invalidated usability cache. The before profile spent about 925 ms of an eight-second sample inside catalog lookup; that hotspot disappeared after the change. A same-manifest benchmark of 100 lookups measured 746.06 ms for the old scan/sort, 14.91 ms to build the index once, and 0.023 ms for cached lookups.
- HUD, aura and periodic-effect presentation clocks stop during local pause/map/companion and while hidden. Target-filtered shallow subscriptions avoid unrelated unit updates; normal dungeon play no longer starts the two invisible race HUD clocks. Visible shared-online timers retain their menu behavior.
- Bounded decoded audio retention to 64 cues or 32 MiB of PCM, with recent-use eviction, shared pending decodes, failure retry, and preserved active playback. This is a cache budget, not a total-process memory limit.
- Stable 20-second paused main-thread busy time improved from 5.41% to 0.49%, reproduced at 0.46%. Stable pause and synthetic hidden-page samples submitted zero draw calls. Resume restored continuous drawing. After three dungeon exits, JavaScript heap returned to 59.59 MiB (59.71 MiB in confirmation), with backing storage near 22.61 MiB. All three runs reported zero page errors.
- A later pause sample showed a transient demand-mode burst; confirmation captured an idle paused CPU profile and zero draws/layouts through an extra settled 20 seconds. The burst was not reproduced or conclusively attributed. Active samples vary with NPC appearance, combat progression, and concurrent project work, so their heap/draw differences are not claimed as isolated optimization gains.
- Reviewed active, resumed and paused screenshots: the player/party/enemy models and starter equipment remained visible, effects rendered, and HUD/menu controls were readable. No visual-quality settings were reduced.
- Validation: 59 focused tests passed; production TypeScript/Vite build, KTX2/environment audits, compression and release-bundle verification passed; release check passed. Scope and remaining original-effect cache/GPU profiling targets are recorded in `docs/PERFORMANCE_CHECK_2026-10-04.md`. These browser counters do not measure whole-system CPU, total RAM, GPU utilization percentage, or VRAM.
## 2026-10-04 — Visible party armor and original RoM starter weapons
- Fixed the presentation gap for synthetic heirlooms and other unmapped armor: WoW player, companion, creator, and roster models now receive native starter/representative chest, leg, boot, glove, shoulder, and cloak appearances. Supported exact drop visuals override the corresponding starter slots. Cloth, leather, and heavy chest appearances differ. This changes presentation only, with no item/stat migration.
- Rebuilt all five native RoM race/gender rigs with the original `cloth010-001` torso, hand, leg, and foot components instead of the bare-body components. All five GLBs passed the Khronos validator with zero errors and warnings. Packaged six original client ROS props for sword, mace, shield, staff, bow, and dagger; retained source and output hashes in their import manifest. Native props use the original RoM socket frame; each item preserves its own error/Suspense boundary and procedural fallback.
- `scripts/playtest-party-equipment.cjs` exercises isolated offline profiles in production headless Edge at 1600 × 1000. It installs each optional character pack before the final native roster/dungeon captures. Fresh profiles initially use the bundled human fallback, so a pre-download screenshot is not evidence for the original RoM rig. The completed WoW/RoM route reported zero page, console, or non-favicon HTTP errors.
- Visually reviewed `playtest-artifacts/party-equipment/WoW-roster.png`, `WoW-party-front.png`, `RoM-roster.png`, `RoM-dungeon.png`, `RoM-party-front.png`, and `RoM-party-open.png`. WoW party bodies have visible robes or armor, shoulder pads, leggings, and capes. The native RoM player and nearby companions wear the source burgundy/orange starter outfit; the mage carries the original textured staff, and the knight carries the original metal mace and wooden shield. Bodies remain visible while equipment loads. The Forsaken Abbey entrance camera crowds the party near a wall, so these captures do not claim every companion is simultaneously unobstructed or certify dungeon positioning.
- Roster previews now receive saved equipped items. Optional equipment completion requests a preview bounds refit after the initial ready-pose blend. Final `RoM-roster.png` shows the full native staff, character body, and boots within the viewport; the earlier immediate refit used bind-pose bounds and cropped the staff tip. The final RoM-only follow-up reported zero browser or asset errors.
- Validation: all 53 avatar tests passed; the production TypeScript/Vite build, asset audits, bootstrap/bundle verification, and release check passed. Refreshed local content as `content-f39a3ad523d546d5`. Original RoM armor is currently a common starter outfit; per-drop modular armor replacement and exact skins for every weapon/armor item remain outside this change. See `docs/CHARACTER_EQUIPMENT_APPEARANCE.md` for regeneration and content-pack requirements.
+7
View File
@@ -228,3 +228,10 @@ The earlier fresh-dist Turtle UI checkpoint passed in a loaded scene at 390×844
Final Cat UI QA on the exact final production build passed for native family/reaction selectors, mouse Stay/Follow, Growl, encounter lock, and Cat persistence after reload while a second character remained Wolf. In the final narrow view, the party row ended at y=269 and the selector card spanned y=306–420 (37px clearance); there was no top-right overlap or horizontal overflow, and the canvas was loaded. Escape opened pause, Spellbook opened, the actual Call Pet entry was clicked, and the overlay closed with Cat still present. This verifies that activation route, not summoning after the pet is absent. Revive was untested because the pet did not die. No live Rake/Prowl cast or Cat model appearance was verified; the only console error was favicon 404, with no page or HTTP errors.
The initial clean-baseline check reproduced four assertions: Divine Punishment produced an extra catalog entry, God's Vengeance lacked its expected proc, the Mage/Druid elite milestone repeated 25 instead of advancing to 30, and Speed Catalysis restored no Energy. All four assertions now pass in the current aggregate, as do the catalog/count assertions. The initial outcomes remain historical; the current status is in [the follow-up report](MECHANICS_BACKLOG_FOLLOWUP_2026-10-02.md). The full `npm test` suite and all-class balance sweeps were not run. Focused tests validate only their named paths and do not certify every ability or talent.
## 2026-10-03 — Native RoM combat follow-up
The requested five identities now share native source amount dispatch and exact-pair rotation policies across the active player and party NPCs. Ordinary RoM NPC pools contain only those five classes; explicit configured factories retain the broader catalog for targeted work. Native-mode health budgets are independent of the preserved WoW-stat RoM curve. The fixed full-player diagnostic completed 80 pulls with no deaths or invalid casts; this does not certify scene encounters or promote readiness.
Focused native integration, stat-mode compatibility, party, source catalog, Ready-five, and avatar checks passed. The separate `combatRotation.test.ts` still has one existing unsupported-only Rogue Killing Spree signature assertion failure (30/31 pass); this RoM pass does not implement Combat Rogue. The full suite/all-class balance sweeps were not run. Source boundaries and final build/browser evidence are recorded in [the combat report](ROM_NATIVE_COMBAT_PASS_2026-10-03.md) and `PLAYTEST_NOTES.md`.
+28 -1
View File
@@ -1,6 +1,16 @@
# Active Class Mechanics Backlog
Updated 2026-10-03. This document lists unresolved ability and talent mechanics for all ten WoW 3.3.5a classes and all ten RuneWaker primary classes. It excludes completed fixes and all visual or sound review. Some items need source confirmation or simulator support before they can be implemented; do not guess missing values. For RoM conflicts, prefer clearly matching current Chronicles of Arcadia behavior over the RuneWaker snapshot; verify skill identity and class pairing, and document the source and any simulator approximation. See [the full audit](ACTIVE_CLASS_AUDIT.md) for the supporting findings and source conflicts.
Updated 2026-10-04. This document lists unresolved ability and talent mechanics for all ten WoW 3.3.5a classes and all ten RuneWaker primary classes. It excludes completed fixes and all visual or sound review. Some items need source confirmation or simulator support before they can be implemented; do not guess missing values. For RoM conflicts, prefer clearly matching current Chronicles of Arcadia behavior over the RuneWaker snapshot; verify skill identity and class pairing, and document the source and any simulator approximation. See [the full audit](ACTIVE_CLASS_AUDIT.md) for the supporting findings and source conflicts.
## Targeted follow-up (2026-10-04)
- [x] Correct pinned AzerothCore creature-immunity foreign-key decoding. Lady Anacondra is Fear/Horror immune, not Silence/Interrupt immune; the source-registered Lightning Bolt regression now cancels under Vow of Silence. Actual Silence/Interrupt-immune creatures retain protection. See [the audit](VOW_OF_SILENCE_AUDIT_2026-10-04.md).
- [x] Verify Vow against Lady's actual scheduled Lightning Bolt and the full 8.4-second silence window. The live check exposed Nature Thorns bypassing spell-kind-only selection because its source delivery is melee; the corrected school/kind gate suppresses new magic casts while preserving physical attacks. Final-build replay confirms cancellation, no nonphysical cast during the status and natural Nature cast resumption after expiry. See [the live report](ROM_VOW_LIVE_FINAL_2026-10-04.md) and [cast-gate source note](ENEMY_SILENCE_CAST_GATE_2026-10-04.md). Boss Testing proof is separate from normal campaign/full-clear coverage.
- [x] Diagnose owned native Priest sustain at levels 60/80 across six consecutive-pull/rest scenarios. All clear without deaths or empty healer Mana; effective healing, overheal and actor attribution are recorded separately. No recovery or healing-AI tuning was justified. See [the audit](ROM_NATIVE_SUSTAIN_AUDIT_2026-10-04.md).
- [x] Audit the Killing Spree representative-signature failure. Spell 51690 is a source periodic-dummy selector with a dedicated player `class-script` handler; the test now checks that exact mechanism while preserving the ten-strike runtime regression. Correct the player damage aura from 1% to the linked DBC child 61851's 20% for two seconds. See [the audit](WOW_KILLING_SPREE_AUDIT_2026-10-04.md).
- [x] Implement shared timed player/companion Killing Spree: five assaults at application and 500-ms intervals through two seconds, retained cast-time target pools, owner-local cleanup, linked +20% final-pulse ordering, actual player/NPC relocation, and learned companion AI selection. Seeded target selection, estimated weapon-child damage/teleport coordinates and unverified active-control semantics remain simulator limits. See [the parity report](WOW_KILLING_SPREE_PARITY_2026-10-04.md). This Combat Rogue diagnostic does not expand Ready-five.
- [x] Audit the five active native RoM identities and correct Knight/Scout Blood Punishment to current CoA's +15% healing received and +5% Holy damage on the actual Blood Arrow aura. Correct companion RoM direct-hit school selection and no-roll outgoing damage-aura consumption; native and compatibility NPC casts are covered separately. See [the five-class report](ROM_FIVE_MECHANIC_GAPS_2026-10-04.md).
- [ ] Reconcile Blood Arrow's current CoA base toggle and per-offensive-ranged-skill cost contract with local 490434. The passive rider is current; the base aura still uses the local 30-second/periodic-HP-cost behavior. Current general and Knight/Scout active identities differ, so do not silently replace one with the other. See the five-class report's remaining base-contract section.
## Native stat input update (2026-10-03)
@@ -221,3 +231,20 @@ RoM characters now retain an independent stat ruleset: existing saved characters
- [ ] Keep the next-Curse instant-cast rider and full Shield Form/Rune Overload target behavior open until their transform, child-state, and target-processing contracts are complete.
- [x] Confirm Rune Overload's RuneWaker identity before comparing it with current CoA: the local generated catalog maps 498572 to Rune Overload and 498571 to Remodeled Body, while the [current CoA Champion/Rogue list](https://chroniclesofarcadia.com/en/wiki/skills/list/10-3) maps 498571 to Rune Overload and 498572 to Runecraft - Haste. The current page and patch history describe a later skill revision; preserve the local RuneWaker names, costs, and values rather than crossing the ID collision.
- [ ] Wire local Rune Overload (498572)'s one-second on-time child tick into player and companion runtime. Catalog work now resolves hidden MagicCollect 498574 from component 621243 as a 0.6 physical-damage coefficient and removes the unsupported 10-yard radius heuristic; the isolated catalog/resolver tests pass (16 tests across the hidden-child and Champion runtime files). RuneWaker component 621241 supplies the one-second `onTimeMagicId` link, but runtime has no autonomous consumer for it; the periodic proc path only receives existing DoT/HoT ticks. The local tooltip says the child hits nearby targets, but the source has no numeric radius or target cap, so target selection remains unresolved and must not be guessed.
## 2026-10-03 — Native RoM five combat and rotations
- [x] Route source-bound native HolyHeal and supported positive magical DotMagic healing through player and companion consumers, including effective Holy Blessing Wisdom and recipient absorption once. Actual player/NPC Heal, Urgent Heal, and Regenerate regressions pass.
- [x] Use verified source attack and automatic training-weapon channels for the five active native builds, with explicit unsupported-component/custom-gear fallback. The resolution follow-up adds native source defense, hit/avoidance and critical consumers for exact imported NPCs; unsupported source inputs retain the shared fallback.
- [x] Add exact-pair native rotations, emergency/group triage, actor/target-owned periodic refresh, and conditional setup/AoE decisions. Public KittyCombo/CoA research informs clean-room policies; the add-on archive was blocked by a Cloudflare challenge, so no imported presets are claimed.
- [x] Run the requested fixed full-player/four-NPC cohort at levels 1/10/30/60/65/66/79/80 against Barren Caves trash and Anglo. All 80 pulls finish without player/NPC deaths or invalid casts. Native-only health budgets give 4.5–9-second trash and 39–53.4-second Anglo pulls in this static diagnostic. Scene mechanics and movement are outside the diagnostic.
- [x] Restrict ordinary RoM companion generation to Knight, Priest, Rogue, Scout, and Mage; explicit configured builds remain constructible for targeted work.
- [x] Add source-negative HP/HP_Per DotMagic application snapshots and ticks for native player, native companion and imported enemy consumers. Source tick defense/resistance is snapshotted once; current MaxHP is read for HP_Per ticks.
- [x] Add native source target-defense/hit/critical consumers, exact per-actor mode dispatch, active-five Throw/ShieldDef inputs and real target-count/ordinal falloff. Keep unsupported source/gear branches explicit.
- [x] Retain enemy damage calculation, attack kind and elemental school independently for WoW and RoM, with source-ID namespaces, DBC school masks and authored fallbacks for missing/non-damaging source rows.
- [x] Resolve supported native NPC equipment and exact fixed/recipient-MaxHP enemy packets; test Anglo movement, interruption, control targeting and player/NPC dispels. Measure enemy damage separately from ability health costs and calibrate the native shared-damage budget through level 80. Source/manual packets bypass that budget. The recovery/equipment follow-up implements source ReHP/ReMP and resolves all 440 imported NPC defaults; unsupported formula shapes and broader sustain certification remain open. See [the enemy/encounter pass](ROM_NATIVE_ENEMY_ENCOUNTER_PASS_2026-10-03.md), [recovery source audit](ROM_NATIVE_RECOVERY_SOURCE_2026-10-03.md), and [equipment completion](ROM_NATIVE_NPC_EQUIPMENT_COMPLETION_2026-10-03.md).
- [x] Implement native source ReHP/ReMP for player/owned NPC actors, resolve all 440 imported NPC equipment defaults, preserve compatibility boss mechanic windows, and correct early native weapon-start/Mana-spending decisions and training budgets. The final fixed 80-pull cohort passes without deaths/invalid casts; see [the recovery/equipment/pacing follow-up](ROM_NATIVE_RECOVERY_EQUIPMENT_PACING_PASS_2026-10-03.md).
- [ ] Complete remaining unsupported source shapes and broader native combat parity before any readiness promotion. RoM builds remain Needs Testing. Level cap stays 80; no loot wiring or readiness promotion.
See [the original implementation report](ROM_NATIVE_COMBAT_PASS_2026-10-03.md) and [the native resolution and damage-type follow-up](ROM_NATIVE_COMBAT_RESOLUTION_PASS_2026-10-03.md). The earlier pacing numbers above describe the historical amount-only diagnostic; the follow-up records the final native-resolution run.
+68
View File
@@ -0,0 +1,68 @@
# Character equipment appearance
The shared `src/avatar/CharacterModel.tsx` renders the player, party NPCs,
roster, and creator. Equipment appearance is presentation-only; it never
changes item ownership, stats, or progression.
## WoW and Ascension
`src/avatar/equipmentAppearance.ts` keeps exact appearances for the packaged
Wailing Caverns drops. Unmapped armor, including synthetic heirlooms, gets a
representative native WoW appearance by equipment slot and armor type. Empty
preview slots receive starter clothing: chest, legs, boots, gloves, shoulders,
and cloak. Cloth uses a robe, leather uses leather armor, and heavy armor uses
the packaged mail breastplate. These are visual defaults, not exact item skins
for every entry in the loot catalog.
Real equipped appearances override the starter appearance in their slot.
The body atlas layers remain ordered so robes override the trouser geoset.
Appearance cache keys include class, item slot, and armor type.
`src/ui/CharacterPreview3D.tsx` passes the saved player's equipped inventory
items into the same renderer used in the dungeon.
Loaded preview attachments request a bounds refit when their optional models
finish loading, so larger native staffs stay inside the roster preview.
## RoM
The native shared-skeleton character GLBs contain original RoM starter
garments. `scripts/runewaker-pipeline/recipes/player-starter-appearance.tsv`
selects `cloth010-001` for torso, hands, legs, and feet instead of the bare
`body000-001` defaults. This common starter outfit is currently shared across
classes; individual RoM armor drops do not yet replace those modular parts.
Rebuild all five supported race/gender rigs with
`npm run runewaker:players:avatars`.
`src/avatar/runewakerEquipment.ts` resolves original RoM starter sword, mace,
shield, staff, bow, and dagger props from
`public/assets/equipment/runewaker-v1/`. Equipped weapon types select the
appropriate representative source prop. Exact skins for arbitrary equipped
RoM weapons are not implemented. WoW texture atlases and WoW item models must
not be applied to the native RoM rigs.
Rebuild the props with:
```text
node scripts/runewaker-pipeline/build-population.mjs --recipe scripts/runewaker-pipeline/recipes/runewaker-starter-weapons.json --actors-only
```
The generated import manifest records the source ROS path, source SHA-256,
output SHA-256, bounds, and triangle count for each prop. Converted source
props retain the RoM socket frame; the WoW-to-RoM procedural fallback rotation
must not be applied to them.
## Packaging and validation
All equipment files belong to the bundled `core:equipment` content pack.
RoM character rigs belong to the optional `characters:runewaker-v1` pack.
Refresh local content with `npm run content:publish` after regenerating assets.
Existing installations must refresh their content packs to receive changed rigs.
Each optional item retains its own Suspense/error boundary and procedural hand
fallback. Missing item assets cannot hide the body or other equipment.
- `npm test -- --run src/avatar`
- `npm run build`
- `node scripts/playtest-party-equipment.cjs` after building and refreshing content
- Review roster and dungeon screenshots in `playtest-artifacts/party-equipment/`
- Record visual findings in `PLAYTEST_NOTES.md`
- `npm run release:check`
+16 -2
View File
@@ -22,9 +22,13 @@ Native derived calculations are sourced from the local RuneWaker `RoleAttr.cpp`
Equipped native items carry `ItemStats.romNative`. Their common WoW projection is not counted again. Foreign items without that payload bridge only direct Strength, Stamina, Intellect, Agility, and flat health/mana under an explicit simulator policy. Spirit does not become Wisdom; WoW armor, attack/spell power, weapons, and ratings do not become native channels. The five existing source-reference heirloom classes supply native gear curves; other classes retain the direct-stat fallback until native gear profiles are sourced. Reference item selection and intermediate-level interpolation remain synthetic simulator policies.
The existing dungeon combat resolver remains a shared simulator. Adapting a verified native stat into a common combat field does not establish that the WoW damage, hit, armor, or critical formulas reproduce RoM combat. Native healing inputs alone also do not certify every RoM healing spell's amount, rank, or coefficient.
The RoM automatic fallback is displayed as Training gear, not an authentic heirloom system. Player and NPC training gear scales through level 79; character base growth continues through level 80. See `docs/ROM_FIVE_NATIVE_SCALING_2026-10-03.md` for the five-class follow-up, owner parity, enemy health policy, and validation. This does not introduce loot progression.
The explicit common adapter uses native ATK as attack/ranged attack power, MDMG as spell power, and DEF as armor. Physical mitigation uses the existing shared WoW armor curve. MATK, MDEF, DMG, native recovery values, and hit/critical/dodge/parry points remain source-semantic sidecar/display fields; native point values are not converted into common percentages. Native common hit/critical/avoidance percentages start at zero. Existing primary resource types and regeneration policy remain part of the shared dungeon simulation; native MP derivation also supplies supported secondary mana pools. The Equipment panel identifies the shared combat boundary, labels point fields explicitly, and shows active native aura effects for its selected owner. Shared HUD counters display whole HP/MP values while their accessible maxima and runtime pools retain source precision.
The follow-up's exact-dist check supersedes the browser artifacts at the shared `playtest-artifacts/stat-rulesets` path. Its final chunks and eleven screenshots are recorded in that follow-up; the validation totals below describe the original stat-ruleset implementation.
The dungeon combat system selects native source resolution for supported canonical RoM builds against exact imported RuneWaker NPCs, in either direction. Unsupported source inputs and WoW-mode actors retain the shared compatibility resolver. Adapting a native stat into a common field alone is not evidence of native formula parity. The latest [native resolution follow-up](ROM_NATIVE_COMBAT_RESOLUTION_PASS_2026-10-03.md) records supported channels and remaining source boundaries.
The common fallback adapter uses native ATK as attack/ranged attack power, MDMG as spell power and DEF as armor, with shared WoW mitigation only on that fallback. The supported native resolver directly consumes source ATK/DEF or MATK/MDEF and native hit/critical/dodge/parry/block points without converting them into common percentages. Existing primary resources and regeneration policy remain part of the shared simulation; native MP also supplies supported secondary mana pools. The Equipment panel labels native points explicitly and shows active native aura effects for its selected owner. DEF displays its source value without presenting a WoW armor percentage as native mitigation. Shared HUD counters display whole HP/MP values while runtime pools retain precision.
Player FIX record 303001 sets `DmgAtkArg` and `MDmgMAtkArg` to zero, so this source version derives DMG/MDMG from flat gear channels rather than ATK/MATK. Its `Dmg_HealArg` is **0.5**. Wisdom/MND, MDMG, MPow, HealPowerRate, HealPoint, and target heal-modifier inputs now have explicit source channels. Per-spell OrgPower, rank/skill-level evaluation, timing, flags, and the complete native healing consumer remain separate work. These local RuneWaker records do not certify current-CoA formulas or balance.
@@ -44,3 +48,13 @@ The character, party member, and combat actor retain their own ruleset. No globa
- Browser evidence: `playtest-artifacts/stat-rulesets/results.json` and its ten referenced screenshots. The checked chunks were `index-CGkT8hBz.js`, `combatStore-CVXgxk1Z.js`, and `PartyFrames-BeW5_TCA.js`. Synthetic engaged-mob and online-activity fixtures verify the menu guards, not a real multiplayer session.
- Final `npm run release:check` passed for 157 changed paths, including untracked files and concurrent unrelated work. Final documentation and owned vault notes satisfy text-file hygiene.
- No full repository suite, all-class balance sweep, RoM readiness promotion, matchmaking, or deployment was performed.
## Native combat amount follow-up (2026-10-03)
This section records the original amount-only pass. Its open native hit/defense/periodic and target-context boundaries are superseded by [the native resolution and damage-type follow-up](ROM_NATIVE_COMBAT_RESOLUTION_PASS_2026-10-03.md), which also records the newest pacing and browser evidence.
`romAbilityCatalog.ts` now carries explicit `romNativeSource` skill/component identities into bound effect amounts. `romNativeCombatAmount.ts` dispatches supported native direct damage, direct HolyHeal, and periodic HolyHeal snapshots from `equipmentAdjustedCombatAmount`; an unsupported source binding or required gear profile leaves the existing shared HealerMan amount fallback in place. Native `MagicProc.MagicLv` currently uses character level clamped to 1–80 as a simulator rule. Active five-build companion casts use the player-build source branch even when the owner is a party NPC.
Direct HolyHeal now uses the source OrgPower/MND/MDMG/MPow/HealPowerRate/HealPoint path with mean variance. Recipient HealAbsorbRate and flat HealAbsorb are applied once at the actual target, then existing generic combat-result stages resolve criticals and overheal. Periodic HolyHeal uses its separate source DotMagic rank correction and snapshots the integer tick amount when applied; player and NPC targets reuse that fixed tick snapshot. Source auto profiles cover player Knight main-hand attacks and NPC Knight/Rogue melee plus Scout Bow attacks, with NPC Rogue offhand. Player Rogue offhand handling requires an explicitly present equipped inventory item. Player Scout Shot uses the native direct-damage route for its actual bound Shoot hit, not a continuous native bow-auto profile. Caster ambient basic attacks retain generic timing and MDMG policy. Player melee haste explicitly reads the existing common aura because native stat re-derivation does not populate common haste fields. Element-specific MPow/BodyRate, unsupported source components, native hit/avoidance point consumption, complete native defense/variance resolution, and full source combat parity remain open. The damage consumer declines share-damage and target-ordinal decrease branches until runtime supplies exact target-count context. Custom/unverified gear cannot enter the source direct-damage training-weapon route and keeps the shared fallback.
The five exact-pair rotation and final full-player diagnostic are tracked in [`ROM_NATIVE_COMBAT_PASS_2026-10-03.md`](ROM_NATIVE_COMBAT_PASS_2026-10-03.md). The post-haste-retention report covers 80 fixed pulls at levels 1/10/30/60/65/66/79/80: all cleared with zero player/NPC deaths and zero invalid casts; trash took 4.5–9 seconds and Anglo 39–53.4 seconds. The scoped health factor applies only to native-stat RoM and leaves legacy WoW-stat RoM, WoW/CoA-origin health, enemy damage, and the level-80 cap unchanged. Source-damage follow-up passed 13 cases: seven helper cases, two player/companion amount-parity and Reflected Shot fail-closed cases, and four actual auto/source-action cases for player Knight main hand, NPC Scout bow, NPC Rogue offhand, and player Scout Shot. Player Rogue offhand handling requires an explicitly equipped inventory item. Player Scout Shot uses the native direct-damage route for its bound Shoot hit. Caster ambient basics keep generic timing/MDMG policy. Final production build and TypeScript project check passed with asset audits, compression, and bundle verification. Exact-dist browser QA passed with 11 screenshots captured and four representative roster, dungeon, NPC-stat, and mobile screenshots visually reviewed. Current chunks were `index-D_h9IRuA.js` and `combatStore-BiA9BOQG.js`; there were no page/server errors and the network response-errors list was empty, with only the optional existing favicon 404 in the console. A release check passed on 249 paths; the final docs update requires the final hygiene/release check. The earlier 60-sample, six-level report is retained as a historical baseline. The ordinary randomized companion pool is now restricted to the active five identities while the configured factory is broader; no pool/readiness promotion is included. No loot wiring, level-cap increase, or legacy/global enemy-HP retune is included.
+187
View File
@@ -0,0 +1,187 @@
# First Conquest of Azeroth party
Updated: 2026-10-04. The project owner selected these five classes as the first
CoA implementation milestone. Build and refine this party before expanding to
other CoA classes. CoA here means Conquest of Azeroth on Vol'Jin; the existing
RoM backlog's Chronicles of Arcadia references describe a different game.
| Seat | Class | Initial specialization | Responsibility |
| --- | --- | --- | --- |
| 1 | Guardian | Vanguard | Tank |
| 2 | Witch Doctor | Brewing | Healer |
| 3 | Barbarian | Brutality | Melee damage |
| 4 | Ranger | Archery | Ranged damage |
| 5 | Pyromancer | Incineration | Caster damage |
Begin with each class's core kit and the selected specialization so the full
party can enter targeted playtests early. Finish the remaining trees within
these five classes before expanding to the other classes. This selects work;
it does not certify the current implementations.
## Implementation order and acceptance
1. Freeze the five class/spec identities and reproducible diagnostic inputs.
Inventory every node, rank, source spell, and current runtime mapping. Author
reviewed talent allocations instead of treating generated role-weighted
allocations as an intended CoA build.
2. Establish the tank and healer loops together, including their resource and
persistent state. Check damage, mitigation, threat, healing recipients,
periodic effects, and party utility against the captured evidence.
3. Implement the three distinct damage loops and their talent interactions.
Share combat primitives where their semantics match, while keeping class
state and learned-rank ownership on the correct actor.
4. Test the same party against explicitly selected encounters. Repeat with
each class as the active player and the other four as companions. Include
single-target and multiple-target pulls, incoming party damage, interrupts,
dispels, cooldown use, resource exhaustion, death, and resurrection where
the selected kits provide them.
5. Fix observed mechanic and AI defects, then inspect live dungeon play and
record evidence in `PLAYTEST_NOTES.md`. Compare balance only after mechanic
correctness. Expand the selected classes' other trees before other classes.
The completion gate is verified behavior for the reviewed core/initial-tree
builds on players and companions, a coherent 1-tank/1-healer/3-damage party in
live playtests, and an explicit record of any unresolved source limitations.
Catalog counts, a boss kill, or passing preparation tests are insufficient.
## Source-backed priorities
The following anchors are from the August Vol'Jin capture in
`src/game/coaLiveData.generated.json`. They identify work to verify; they are
not claims that these mechanics are implemented.
- Guardian: Footman's Calling (node 4019, spell 92105) establishes formation
behavior; Vanguard's Shield (34417, 804383) builds stacks through blocks and
auto attacks; Paragon (29969, 706514) transforms Heavy Blow at 10 stacks and
interacts with Brace. Audit formation gates, actor-owned stacks, block events,
threat, and defensive sequencing together.
- Witch Doctor: Cauldron Brewer (4005, 92085), Ingredient: Jungle Shrooms
(12645, 801660), Potion Toss (12646, 801661), and Mixologist (6645, 561069)
establish cauldron/ingredient state, nearby ally effects, and ingredient-based
changes to a targeted heal. Verify recipient, radius, periodic cadence,
ingredient exclusivity/capacity, and healing formulas before adding interactions.
- Barbarian: Decapitate (5744, 804414) requires a target below 35% health and
converts additional Energy into damage; Beheader (6744, 705222) refunds Energy
after an eligible kill; Battle Rhythm (14303, 800193) generates Energy on
ability critical hits. Verify event origin and payment/refund ordering.
- Ranger: Precision Shot (31178, 500075), Hunting Shot (29660, 801191), and
Masterful Archery (7794, 680469) depend on Advantage, Elude, ricochet hits,
and stacking effects. Audit the state and target selection before AI priorities.
- Pyromancer: Ignite (6985, 800791), Combustion (29854, 520884), and Pyroclasm
(7174, 520019) depend on Heat, Embers, and the caster's burning effects on a
specific target. Verify periodic generation, spending, effect counting, and
effect consumption. The August Agonizing Burns/Timed Attacks payload exchange
also requires explicit saved-build review.
Current generated resource profiles and generic effects do not prove these
stateful loops are supported. Unspecified proc timing, missing coefficients,
and unknown server rules remain evidence gaps; never invent their values.
Use the cached Vol'Jin capture and global Spell data described in
[COA_REFERENCE_PIPELINE.md](COA_REFERENCE_PIPELINE.md).
## Repeatable preparation and targeted diagnostics
`src/game/coaFirstPartyRoster.ts` owns the exact roster. The benchmark helper at
`src/game/benchmark/coaFirstParty.ts` owns build selection, companion construction,
and explicitly scoped Balance Lab configurations.
`scripts/coa-first-party/prepare.ts` produces the review inventory and five
active-player configurations, retaining the same party build IDs in each.
```sh
npm run coa:party:prepare -- --list-bosses ragefire-chasm
npm run coa:party:prepare -- --level 60 --boss <exact-key-from-list>
npm run coa:party:test
```
Preparation writes ignored local artifacts to
`artifacts/coa-first-party/level-60/`: `audit.json`, `audit.md`, `config.json`,
and `seat-1.json` through `seat-5.json`. It records the source capture/hash,
legal generated talent allocations, source gaps, and runtime representations.
Every mechanic begins as `needs-review`; mapping is not verification.
It downloads nothing and runs no encounter simulations.
Once an explicit diagnostic is appropriate, the existing runtime consumes a
prepared configuration without changing the ordinary companion pool:
```sh
npx vite-node --mode balance-lab scripts/balance-lab/runtime.ts simulate --config artifacts/coa-first-party/level-60/seat-2.json --output artifacts/coa-first-party/level-60/seat-2-runs.json
```
Only the selected five and explicitly named bosses belong to this milestone's
diagnostics. All five remain **Needs Testing**. Do not change Ready policy,
random/eligible pools, default regression scope, or all-class sweep coverage.
Ordinary live party generation currently uses the Ready WoW pool, including
for a CoA player; a complete CoA party must be configured explicitly.
For implementation changes, run the smallest affected tests plus the existing
Ready-five checks, build, and `npm run release:check`. Record each mechanic's
source, player/companion checks, and live evidence as it is completed.
## Initial implementation and evidence limits
The first catalog fix exposes sourced active Talent entries for these five
classes. Talent Essence purchase type no longer prevents an active spell from
becoming a usable action. The action retains its talent-node/rank gate. DBC
passive flags and rank metadata keep passive Talent nodes on the existing
modifier path; missing DBC rows cannot create a new action. The other 16 classes
retain their previous action catalogs.
This fixes action availability, not the complete effect of each spell. Initial
Potion Toss checks cover its base targeted heal and mana payment for a player
and companion; ingredient riders and its full authored scaling remain work.
Decapitate's target-health gate is a separate first mechanic; additional Energy
conversion and the Beheader refund remain pending.
Ranger's primary resource is now Focus, matching its spell costs. Player and
companion checks cover spending, the displayed/authoritative pool, and recovery.
The existing authored 100-point pool and 10-per-second recovery are retained;
this correction does not establish source-verified regeneration or implement
Advantage/Elude.
Brewing has specific evidence limits beyond the node-rank source inventory:
Mojo Beam's capture describes eight seconds while the selected DBC says four;
Spirit Link Idol's tooltip says fourteen seconds while its DBC says 8.5. The
current promoted catalog omits the Loa's Blessing empowerment payloads
802207/802208/802215/802216 and Spirit in a Bottle's referenced
504606/707172 payloads. Resolve those exact references against the local/raw
cache before implementing their full behavior. Zero missing node-rank DBC rows
does not establish that every mentioned payload or server rule is known.
## Kickoff validation and diagnostic baseline
On 2026-10-04, 87 focused tests across 15 files pass, including the exact roster,
learned Talent actions, Potion Toss delivery, Decapitate's health restriction,
Ranger Focus, source scope, and classic catalog preservation. Ready-five checks
pass seven game tests and twenty Python tests. The production build passes
TypeScript, asset audits, bundling, compression, and bundle verification.
`npm run release:check` passes, including untracked files. A before/after digest
also confirms the other 16 CoA action catalogs are unchanged.
The targeted automated baseline uses the same five generated level-60 builds,
seed 1, and Taragaman the Hungerer (`ragefire-chasm/entry-11520`). Each run has a
30-second limit and moves only the active player seat. Results after the Focus
correction are:
| Active player | Outcome | Elapsed seconds | Boss health remaining | Deaths |
| --- | --- | ---: | ---: | ---: |
| Guardian | Time limit | 30.0 | 80,065 | 0 |
| Witch Doctor | Time limit | 30.0 | 79,292 | 0 |
| Barbarian | Defeated | 12.6 | 0 | 0 |
| Ranger | Time limit | 30.0 | 12,782 | 0 |
| Pyromancer | Time limit | 30.0 | 14,724 | 0 |
Raw runs are local ignored artifacts at
`artifacts/coa-first-party/level-60/seat-1-runs.json` through `seat-5-runs.json`.
Companion action counts are substantially lower than player counts; Ranger and
Pyromancer companions each cast only three times in the healer-seat run. That
run's Ranger still records 32.3% Focus starvation. The profile correction alone
does not resolve their rotations, stateful costs, or generic effect semantics.
The large player-seat differences require mechanic and AI investigation before
balance changes. This low-pressure encounter also provides limited evidence
for tank/healer interactions.
These are explicitly scoped automated diagnostics, with no live browser
playtest or balance certification in this kickoff. All five remain Needs
Testing. The next implementation pass is Guardian's Vanguard state together
with Witch Doctor's cauldron/ingredient loop and reviewed talent allocations,
followed by the three damage loops and companion rotation parity.
@@ -0,0 +1,52 @@
# Enemy Damage Types Audit — 2026-10-03
## Scope
This audit follows imported enemy attack damage type data from the WoW 3.3.5a and RuneWaker source catalogs into `EnemyAttackDefinition`, normalization, and combat hit resolution. It records verified source channels separately from HealerMan's single-school runtime representation. It does not claim complete native damage formula coverage.
## Findings
The two games use separate numeric spell namespaces. Runtime source lookup is therefore keyed by `(damageOrigin, spellId)`: `wow335` is matched only against the WoW Spell.dbc export, and `runewaker` only against the RuneWaker spell catalog. RuneWaker NPC identity is the imported template/NPC ID (`runewakerNpcId`); it must not be confused with a WoW `serverEntry`.
For WoW, `scripts/dungeon-pipeline/compile-campaign-catalog.mjs` now retains Spell.dbc `DmgClass` (field 213) and `SchoolMask` (field 225) on parsed source rows and generated enemy attacks. Runtime `attackKind` follows DmgClass where known: 1 → spell, 2 → melee, 3 → ranged. The compact source exporter reads only spell IDs referenced by compiled campaign attacks and WoW boss mechanics, and retains rows with a recognized harmful damage effect. The effect set currently includes 1, 2, 7, 9, 17, 31, 58, 62, and 121. It excludes non-damaging script records even when their DBC school is populated; for example, Call Flames (48258) and Ritual of the Sword (48331) keep authored runtime typing.
`src/game/mobDamageTypes.ts` converts the seven recognized WoW SchoolMask bits into an ordered list without selecting a “strongest” school. A singleton mask can supply `school`; a composite or unknown mask retains the authored school fallback because combat mitigation still resolves one school per event. Two generated composite examples are Crystalfire Breath (48096, fire + frost) and Death's Bite (67808, frost + shadow). The source list preserves both values, but current combat does not apply two simultaneous school-mitigation passes.
For RuneWaker, packet class, hit-gate type, and resistance channel are separate source values. `MagicProcess.cpp` branches on `MagicBase.Attack.CalType` for direct damage at line 2322 and again for hit/critical handling around lines 3478–3757. The verified direct packet mapping is CalType 0/3/9 → physical and 1/10 → magical; runtime delivery is 0/9 melee, 3 ranged, and 1/10 spell. Other types (including Throw 2, Fix 4, SpecialAction 7, and FixPer 8) remain unclassified by this compact damage-type layer. They keep raw source metadata and are not guessed into a physical/magical packet class. `MagicCollect.EffectType` is inspected separately for the physical-vs-magic hit gate (`MagicProcess.cpp` around line 1648), while raw `MagicObject.EffectType` is retained for effect semantics; neither is used as the direct packet-class field.
`MagicResistENUM` in `SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h` identifies `MagicType` -1 as physical and values 0–5 as Earth, Water, Fire, Wind, Light, and Darkness. The compact catalog keeps this exact `sourceChannel`, independently of `calculationType`: physical remains physical, Earth/Wind → nature, Water → frost, Fire → fire, Light → holy, and Darkness → shadow. The seven-school runtime values are compatibility approximations; the original channel and component ID remain available. Component-specific resolution requires `componentId` whenever a source spell has mixed component classes or channels.
Two imported counterexamples demonstrate why the old `MagicObject.EffectType` interpretation was wrong. Holy Seal (spell 490149, component 500142) has CalType 0, raw EffectType 0, and MagicType Light (4): it is a physical melee packet on the Light/holy channel. Dark Energy Strikes (spell 498602, component 621306) has CalType 1, raw EffectType 1, and MagicType Darkness (5): it is a magical spell packet on the Darkness/shadow channel. Deadly Water Pressure (490045, component 500056) has unsupported CalType 8; it retains Water/frost, but its packet class is left unclassified. The standard spell catalog now applies the same source-derived class, school, attack kind, and normalized `runewakerEffectType` (0 magic, 1 physical) policy; raw component effect types remain in source metadata.
`scripts/runewaker-pipeline/build-population.mjs` stamps imported attacks with `damageOrigin: "runewaker"`, the source runtime `attackKind`, and `runewakerEffectType`. Classification is not inferred from the mapped school: a magical projectile can still be ranged. WoW attacks are stamped `damageOrigin: "wow335"` with raw DmgClass and SchoolMask. `normalizeEnemyAttack` uses source records only when the source row contains recognized damage components, so script/control placeholders do not replace manually-authored schools.
## Periodic damage coverage
The enemy combat runtime now supports authored `kind: "dot"` attack effects. Application creates a dispellable magic debuff, schedules the first pulse at one interval after cast resolution, and resolves each pulse through the ordinary school mitigation and absorb path. Pulses do not roll a fresh hit, miss, crit, dodge, parry, or block. The final pulse may occur at the aura's exact natural-expiry timestamp. A magic dispel removes future scheduled pulses for that target.
The focused integration test exercises an enemy Frost DoT against both the player and a party NPC: no damage at cast start or application, one-second pulses, exact-expiry final tick, and cancellation of the player's remaining ticks after a magic dispel. This verifies the scheduler and event school path. It does not claim that source-native RuneWaker DoT amount snapshots are wired to every NPC yet; those require an exact imported NPC/source-skill/component binding and the native formula's additional source/target inputs.
## Source provenance and generated data
- Current compact export contains 1,653 WoW candidate IDs, 671 WoW damage-spell rows, and 471 rows with direct-damage effect 2. It also contains 3,199 RuneWaker catalog spells and 1,342 exported source spells with 1,398 direct-HP-damage components. Of those components, 512 map to physical packet class, 284 to magical packet class, and 602 have an unsupported/unclassified CalType (including types 2, 4, 7, and 8). The WoW exporter excludes RuneWaker-origin boss mechanics before looking up numeric IDs in the DBC.
- WoW binary source: local WotLK 3.3.5a `Spell.dbc` at `../wow-pvt-server-backups-2/wow335a/Data/patch-A.MPQ/DBFilesClient/Spell.dbc`; SHA-256 `57011d528fd66cf39f5ab6fb168e389ab9e4cc1b8050f7201a54239d489b415c`, 234 fields and 936-byte rows.
- WoW field layout reference in the exporter: AzerothCore `DBCStructure.h`; fields consumed are DmgClass 213, SchoolMask 225, and Effects 71–73.
- RuneWaker source enum: `SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h` (`EM_MagicAttackCalBase` around line 3967 and `MagicResistENUM`). Direct packet and hit/critical dispatch are in `SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp` (direct formula around line 2322, hit/crit dispatch around lines 3478–3757; `MagicCollect.EffectType` hit gate around line 1648). The generated spell snapshot and compact catalog preserve the source calculation type, raw effect type, channel, and component identity.
- RuneWaker spell source catalog: `scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json`; current SHA-256 `8a6a5a1629db66dd8df02a559ed01c0f5f746a5bb2112093b5223474be5997ed`.
- Regenerate compact source metadata with `node scripts/dungeon-pipeline/export-enemy-damage-types.mjs` after refreshing source spell catalogs.
## Focused verification
The focused run passed 26 tests across `mobDamageTypes.test.ts`, `enemyDamageTypes.integration.test.ts`, `enemyAttacks.test.ts`, and `enemyCasting.test.ts`. The tests include actual RuneWaker catalog counterexamples and confirm that exact component metadata replaces stale imported attack kind/school during normalization; the integration suite also records Frostbolt and RuneWaker physical-hit events plus the authored periodic lifecycle described above.
## Formula-specific follow-up
The compact exporter also preserves `directFormula` and `formulaMitigation` separately from packet class, raw component EffectType and resistance channel. Exact NPC-bound CalType 4/8 direct packets now use `romNativeEnemyDamage.ts`; the formula's source absorber does not imply a physical/magical packet class or generic DEF/resistance pass. CalType 2 remains source-audited but has no exact imported NPC binding or post-combat-table completion hook. See [the manual enemy formula audit](ROM_NATIVE_ENEMY_DAMAGE_SOURCE_2026-10-03.md) and [the encounter/pressure report](ROM_NATIVE_ENEMY_ENCOUNTER_PASS_2026-10-03.md).
## Remaining limits
- Runtime mitigation still stores one school per damage event. Composite WoW masks are source-preserved and currently use the catalog's authored single-school fallback in combat.
- RuneWaker CalType 2/4/7/8 and any other unmapped calculation type do not receive a packet class in this exporter. Exact manual CalType 4/8 consumers use the separate formula metadata; unsupported formula/source branches retain their authored fallback.
- RuneWaker channel-to-runtime-school translation is approximate by design; retain raw channel/effect type for future exact resistance work.
- Native RuneWaker enemy DoT amount resolution is source-bound, not name-derived. Until the matching binding and required inputs exist for an NPC spell, its authored generic periodic amount remains the documented fallback.
- Unclassified DBC effect IDs, non-damage/script effects, unknown school-mask bits, source rows absent from the local databases, and effect/component interactions outside the importer scope remain authored or unsupported rather than guessed.
@@ -0,0 +1,21 @@
# Enemy Silence Cast Gate — 2026-10-04
## Finding
Lady Anacondra's imported spell 8148 is Thorns. The generated Wailing Caverns reference records it as a cast-targeted Nature aura from SmartAI row 4 (`Lady Anacondra - In Combat - Cast Thorns`): `school: nature`, `delivery: melee`, `animation: cast`, and `effect.kind: aura`. The source entry has no `attackKind` or `damageClass`. Since `enemyAttackKind` falls back to `melee` for `delivery: melee`, the previous selection rule (`silenced && attackKind === "spell"`) allowed Thorns to start while Silence was active.
The same Lady Anacondra reference contains spell 9532 Lightning Bolt as a Nature projectile cast from SmartAI row 1 (`Lady Anacondra - In Combat - Cast Lightning Bolt`). Existing active-cast handling already cancels nonphysical casts when Silence takes effect.
## Runtime change
Enemy attack selection now rejects an attack during Silence when its school is nonphysical or its resolved attack kind is `spell`. This uses school and attack kind only for silence eligibility. It leaves shared attack-kind and damage-roll classification unchanged, allows physical melee and ranged attacks, and still suppresses a physical-school attack explicitly classified as a spell.
## Focused coverage
`src/game/enemyCasting.test.ts` uses Lady Anacondra's generated source entries for Thorns 8148 and Lightning Bolt 9532, plus the imported physical melee Tendon Rip. It checks that Lightning Bolt cancels when Silence lands, Thorns neither starts nor resolves over repeated advances in the 8.4-second window, Thorns starts and resolves after Silence expires, physical melee and ranged attacks remain eligible, and physical spells remain blocked. The imported NPC ability data has no separate silence-prevention field; for Thorns it also carries no `attackKind` or `damageClass`, so the test exercises the exact school/delivery ambiguity from source.
The selection test stages a Silence status directly to isolate NPC cast behavior. The separate full Vow of Silence scenario remains the end-to-end validation path for applying the actual player ability and observing its combat result.
## Validation
`node_modules/.bin/vitest run src/game/enemyCasting.test.ts src/game/romPriestChampionRuntime.test.ts src/game/wailingCavernsParity.test.ts` passed: 3 files, 30 tests. Use the direct Vitest binary for this focused workflow; `npm test` invokes the `pretest` loot-generation hook. No build or repository-wide test suite is part of this narrow change.
+53
View File
@@ -0,0 +1,53 @@
# Legacy Boss Pacing Fix — 2026-10-03
## Change
The existing 25-second ordinary-boss and 45-second final-boss minimums now hold for the measured legacy cohorts without changing those assertions or reducing encounter timing thresholds.
The Stockades WoW starter health anchors are now 1,800 at enemy level 2 and 2,600 at enemy level 4. Those replace 1,050 and 1,500, respectively. At level 1, the measured Priest plus four Ready companions now clear the ordinary and final bosses in 25.3 and 45.7 seconds. At level 3, they clear in 25.6 and 48.1 seconds.
The compatibility retune documented here uses a separate player-level multiplier only when `characterSource === "rom"` and `statRuleset === "wow"`: 1.0 through level 7, 1.12 at level 8 through 70, then a linear increase to 2.3 at level 80. This applies to boss health floors only. This compatibility retune did not change trash health, the native RoM health bands, native damage-budget bands, enemy damage, loot, cap 80, actor mode defaults, or Ready policy.
## Why the RoM compatibility branch stays separate
The pacing harness calls `dungeonEnemyScaling` for RoM parties without passing `statRuleset`, so it intentionally exercises the legacy `wow` ruleset default for those RoM characters. Generated RoM NPCs still use their native snapshots by default. This is a supported mixed-mode cohort; the harness calls and actor mode defaults remain unchanged.
The compatibility multiplier is gated on that existing legacy `statRuleset: "wow"` path. At the time of this compatibility retune, native-mode RoM boss health remained exactly 8,330.293333 at player level 8 and 1,312,656 at level 80 for the Stockades regression fixture; `nativeDamageBudgetScale` remained 1 and 40. A later, independently measured native level-one boss calibration changes the interpolated level-8 value to 7,983.197778 while preserving the level-80 value and both damage-budget values. See [the native level-one boss pacing addendum](ROM_NATIVE_LEVEL_ONE_BOSS_PACING_2026-10-03.md). The compatibility retune did not edit either native table; the later calibration changes only the level-1 boss-health anchor and leaves `ROM_NATIVE_DAMAGE_BANDS` untouched.
The equipment completion added optional off-hand and bow damage/speed/range values to native NPC snapshots. Those snapshot fields are available to future consumers; they do not make the shared enemy action scheduler emit additional off-hand or bow swings, and they do not automatically increase its shared enemy damage budget. This pacing change does not add extra enemy attacks or claim that the live swing path consumes those channels.
## Before and after evidence
The untouched baseline was captured with `BOSS_BALANCE_REPORT=1` and `NODE_OPTIONS=--max-old-space-size=8192` before retuning. Its 65 samples are archived at `artifacts/rom-enemy-followup/before-compatibility-pacing.json`. The test suite stopped at the first failing assertion within each affected test, so the baseline has only the first seed for the failures below.
| Cohort and level | Before ordinary / final (health) | After ordinary / final (health) |
| --- | --- | --- |
| WoW Stockades, level 1 | 14.6s / 27.1s (1,050 / 1,995) | 25.3s / 45.7s (1,800 / 3,420) |
| WoW Stockades, level 3 | 15.1s / 28.6s (1,500 / 2,850) | 25.6s / 48.1s (2,600 / 4,940) |
| RoM damage Mage, level 8 | 23.6s / 46.3s (15,619 / 29,677) | 26.8s / 52.9s (17,494 / 33,238) |
| RoM party, level 80 | 11.3s / 23.6s (273,470 / 519,593) | 27.1–28.1s / 53.3–57.1s (628,981 / 1,195,064) |
After retuning, the report contains 83 samples: all three WoW seeds at levels 1, 3, 8, 20, 40, and 80; all five damage-Mage levels; and all 12 standard RoM party seeds at levels 1, 8, 20, 40, and 80. The complete report is archived at `artifacts/rom-enemy-followup/after-compatibility-pacing.json`.
Across the post-change samples, ordinary/final clear-time ranges were:
| Cohort | Level | Ordinary boss | Final boss |
| --- | ---: | ---: | ---: |
| WoW party | 1 | 25.3s | 45.7s |
| WoW party | 3 | 25.6s | 48.1s |
| WoW party | 8 | 33.1s | 61.0s |
| WoW party | 20 | 48.1–49.0s | 93.8–97.0s |
| WoW party | 40 | 40.8–45.4s | 78.4–81.3s |
| WoW party | 80 | 95.3–100.1s | 191.7–193.7s |
| RoM damage Mage | 1 | 42.1s | 109.3s |
| RoM damage Mage | 8 | 26.8s | 52.9s |
| RoM damage Mage | 20 | 30.1s | 62.6s |
| RoM damage Mage | 40 | 35.1s | 69.1s |
| RoM damage Mage | 80 | 27.1s | 57.1s |
| RoM party | 1 | 81.1–92.5s | 191.2–199.4s |
| RoM party | 8 | 41.2–42.6s | 103.7–106.9s |
| RoM party | 20 | 36.6–40.3s | 73.6–75.1s |
| RoM party | 40 | 38.1–42.1s | 78.1–79.6s |
| RoM party | 80 | 27.1–28.1s | 53.3–57.1s |
The full `dungeonBossScaling.test.ts` suite passed all 18 tests. The additional focused regressions passed all 6 tests across `romNativeDungeonPacing.test.ts` and `biteSizedBossBalance.test.ts`. The real-mechanics Priest test also passed its boss-attack, incoming-damage, healing, and survival assertions; its helper does not add that run to the timing report.
@@ -0,0 +1,78 @@
# Mechanic parity and campaign boss pass - 2026-10-04
## Outcome
Timed player/NPC Killing Spree and source-confirmed active-five RoM fixes are implemented. Vow's live counter and full 8.4-second silence pass after correcting the source Nature Thorns cast gate. Normal Barren fully clears in 13:36.9 with one casualty; strict Wailing reaches and defeats Lady with all five actors alive, while full Wailing completion remains unverified. Sampled camera visibility/right-drag checks pass. The final build, 296 focused assertions, Ready-five checks and repository/vault hygiene pass. Blood Arrow's separate base toggle/cost migration remains open.
## Authorized scope
Complete Killing Spree's timed player/owned-NPC mechanism; verify Vow of Silence in an actual Lady Anacondra fight; extend normal Barren Caves and Wailing Caverns routes to bosses and attempt full clears; audit and implement source-confirmed gaps for the five active native RoM identities. Independent work runs through GPT-6 Luna with extra-high reasoning.
The native set is Knight/Scout tank, Priest/Knight healer, Rogue/Warrior damage, Scout/Rogue damage, and Mage/Warlock damage. This is a targeted diagnostic and implementation pass, not an all-class balance sweep or a readiness promotion. The five WoW Ready builds retain the routine certification scope.
Level cap 80, automatic Training gear, saved-profile stat compatibility and loot scope are preserved. No commit, deployment or live-report status mutation is included. Existing shared-workspace changes are preserved.
## Evidence ownership
- `docs/WOW_KILLING_SPREE_PARITY_2026-10-04.md`: source-timed player/NPC mechanism, cleanup and focused tests.
- `docs/ROM_FIVE_MECHANIC_GAPS_2026-10-04.md`: exact active identities, confirmed fixes and unresolved source/runtime contracts.
- `docs/ROM_CAMPAIGN_BOSS_PASS_2026-10-04.md`: normal campaign traversal, boss/full-clear attempts, live Vow evidence and screenshots.
- `docs/ROM_BARREN_FINAL_CLEAR_2026-10-04.md`: independent final-build normal Barren rerun.
- `docs/ROM_VOW_LIVE_FINAL_2026-10-04.md`: independent final-build ordinary Boss Testing Lady/Vow fight, separate from campaign completion.
- `docs/ENEMY_SILENCE_CAST_GATE_2026-10-04.md`: source-confirmed Thorns silence-selection correction discovered during the full live observation window.
## Validation boundaries
Exploratory current-dist captures are labeled separately from final-build checks. Normal campaign movement and casting use ordinary inputs; staged casts, teleports and edited combat state cannot establish live-route proof. Browser screenshots require visual inspection. A completed harness recording does not establish a successful clear, survivor count or counter cancellation without matching telemetry.
## Camera implementation
Normal Wailing traversal reproduced a foreground pillar obscuring the player and companions while HTML labels remained visible (`playtest-artifacts/native-routes/baseline-20261004-134537874/wailing-caverns-segment-end.png`). The player follow camera queried physical terrain only; the spectator follow camera already included the dedicated visible wall/pillar layer. `src/scene/dungeonCameraObstruction.ts` now shares that combined query, preserving the physical sphere sweep, actor exclusion and immediate pull-in/eased pull-out behavior. Sight-only sensor proxies now shorten the player boom as well.
Real Rapier obstruction tests plus existing line-of-sight and input tests pass 41 assertions across four files. Final-build live camera/route evidence is recorded in the campaign report; this static result alone does not certify every cave viewpoint.
## Mechanic implementation
Killing Spree now executes five scheduled owner-local assaults through the two-second window for players and generated Rogue companions. The learned companion action enters its existing Combat rotation; damage/aura ordering, retained targets, actual scene relocation and death/reset/roster-removal cleanup have focused coverage (71 tests). Seeded target choice and estimated weapon-child/teleport details remain simulator approximations.
The active-five audit corrects Knight/Scout Blood Punishment to +15% received healing and +5% Holy damage on the live Blood Arrow aura. Actual companion casts exposed and corrected presentation-derived RoM combat schools and missing outgoing aura handling on the compatibility no-roll direct path; native roll-based damage must not apply those modifiers twice. The audit retains unresolved source values and the separate Blood Arrow base-toggle/cost migration explicitly.
Routine Ready-five certification passes seven game and twenty Python tests. Final production build and browser evidence are required separately below.
## Campaign build and integration checks
- Focused suites: 71 Killing Spree/runtime/registry, 154 active-five pair/integration, and 41 camera/line-of-sight/input assertions pass (266 total across eleven files).
- Ready-five: seven game assertions and twenty Python tests pass. No all-class balance sweep or readiness promotion.
- `npm run build` passes application/project TypeScript, Vite, prebuild texture/performance audits, postbuild compression and release-bundle verification. The existing prebuild loot-catalog generation runs unchanged; this task adds no loot wiring.
- Campaign assets: `index-DHQInfBp.js`, `combatStore-Cx06rFzG.js`, `GameRuntime-D87YWi4Q.js`, `PartyFrames-5jlxKxxK.js`, `romAbilityCatalog-CHjYfHkT.js`, `index-499eecpe.css`.
- Recorded browser scope: independent normal campaign Barren/Wailing attempts and a separate ordinary Boss Testing Lady/Vow fight. The later silence-correction replay identifies its rebuilt assets below; failed exploration is retained separately.
## Verified live outcomes
Barren Caves clears through normal campaign movement and combat on the final asset set. The native result overlay reads **FULL DUNGEON CLEAR**, Anglo defeated 1/1, at **13:36.9**; the boss record independently shows dead with zero HP. The initial level-30 Priest/Knight naturally finishes at level 36. Rorik dies; the player, Caelan, Durnik and Veyra survive (four of five actors). Effective healing totals 46,328.47. This establishes one full clear, not a death-free balance certification. Maximum sampled player-to-living-companion distance is 12.52 m during the route and 15.56 m in post-pack snapshots. No loot action was taken. See the independent Barren report and `playtest-artifacts/campaign-boss-pass/final-build-20261004-150021873/`.
The initial independent ordinary Boss Testing Lady fight confirms Vow cancels a real source Lightning Bolt (9532), without staging the cast or changing combat state. Before Vow, source creature 3671 has an interruptible Nature bolt and only Fear/Horror mechanic immunities. The completed Vow event targets Lady; immediately afterward the cast is null and her actual Silence status lasts 8,400 ms. The aura applies before the explicit interrupt effect, so it cancels the cast before that effect can set a Nature school lock. A zero school-lock value is not failure when the silence and cast cancellation are verified. Before/after screenshots show the bolt bar disappear; no presentation-event capture is claimed. The fight defeats Lady with all five actors alive and no XP/loot. Evidence: `playtest-artifacts/vow-live-final/final-build-20261004-151254152/`; the scoped report distinguishes the original harness false-negative from successful cancellation. Longer observation in `final-build-20261004-151736260/` finds Nature Thorns starting during silence, leading to the enemy selector correction and successful rebuilt replay below.
Normal Wailing movement has revisited the vicinity of the previously clipped view on the final build. The screenshot triplet shows loaded bodies and equipped silhouettes, right-drag rotation, and a stable released view. The foreground rock that obscured the earlier sampled view is absent in this nearby pose. The campaign report records the exact pose/input telemetry and final route outcome; this is a sampled camera improvement, not universal cave visibility certification.
Wailing's strict route reaches Lady's authored anchor without the earlier fall: 433.96 m actual travel against 432.42 m guidance, all 284 checked movement vertices reached, no route block and five actors surviving. Effective healing totals 28,587.81. Lady engages and is defeated during ordinary pack stabilization before the endpoint. Real source Lightning Bolt damage, repeated player Rising Tide casts, a final clipped companion hit and the captured feedback **That target is already defeated** establish the fight/defeat. The original endpoint-only `boss-not-reached-or-not-loaded` classifier misses this natural midroute fight after the boss leaves active state; it is not evidence of a spawn defect. The run does not defeat the other seven required bosses or complete the Naralex/Mutanus event, so **full Wailing clear remains unverified**. Empty guard-attempt records do not prove those living packs cleared. Evidence: `playtest-artifacts/campaign-boss-pass/final-build-20261004-151549020/`.
The Wailing camera probes rotate yaw by -0.378 radians while right mouse is held, with `document.pointerLockElement` null. After release, an unheld pointer move changes yaw by zero. Player displacement during either drag is less than 0.001 m. The earlier clipped-position probe is 5.18 m from that old pose; the improvement is a nearby sample, not an exact-position comparison or a measured rendered-camera boom extraction. Character bodies and staff/bow/shield silhouettes were visually inspected at route checkpoints and both probe triplets.
## Silence correction and final replay
The full live observation exposes the actual pinned Thorns 8148 row: Nature school, self aura, `delivery: melee`, and no explicit damage class/kind. The old new-cast selector inferred melee from delivery and let it start during silence; active-cast cleanup then canceled it on the next advance, producing repeated starts without resolution. Enemy selection now checks nonphysical school **or** explicit spell kind for silence eligibility. Physical melee/ranged remain available and the damage-roll classifier is unchanged. The imported attack contract has no prevention-type field, so this remains the simulator's explicit school/kind control policy. The separate source report and actual-source regressions cover repeated silence advances, expiry and resumed Thorns release. A coordinated rebuild and independent live replay follow this correction; the earlier campaign/camera evidence retains its recorded asset identities.
After the correction, `npx vitest run src/game/enemyCasting.test.ts src/game/romPriestChampionRuntime.test.ts src/game/wailingCavernsParity.test.ts` passes 30/30. The combined scoped validation is **296 assertions across fourteen files** (266 mechanic/camera assertions plus these thirty); Ready-five is rechecked at seven game/twenty Python passes. The final `npm run build` passes project TypeScript, Vite, pre/post audits, compression and bundle verification. Corrected assets are `index-Cm7Olrd2.js`, `combatStore-1B4KOVhS.js`, `GameRuntime-hM1AaOYR.js`, `PartyFrames-BfZEkXH5.js`, `romAbilityCatalog-DjRUCJbs.js`, and `index-499eecpe.css`. The independent Vow replay uses this corrected set; the completed campaign routes were recorded on the preceding set.
The corrected live replay **passes** in `playtest-artifacts/vow-live-final/final-build-20261004-153859692/`. The ordinary Vow input cancels Lady's real source 9532 before scheduled completion and applies silence from 1791128371374 to 1791128379774 (8,400 ms). No nonphysical active cast is observed during that actual status window, including Thorns. Lady naturally begins the next source Nature Lightning Bolt at 1791128379835, 61 ms after expiry. The fight then defeats Lady with all five actors alive, no XP/loot and no readiness change. Parent and subprocess visually inspect before/after cast screenshots and the boss-defeated panel; loaded character bodies and equipped silhouettes remain visible. No cast-cancel presentation-event capture is claimed. This supersedes the earlier Thorns suppression failure without replacing its historical evidence.
## Remaining limits
- Full Barren clear and Wailing's first-boss route/defeat are verified; full Wailing completion remains unverified.
- Blood Arrow's current CoA base-toggle/cost identity migration, unquantified pair values and Killing Spree's documented simulator approximations remain open in their scoped audits.
- The camera improvement and ordinary right-drag policy are verified at sampled Wailing views; every dungeon viewpoint and compact gameplay layout are not certified by this pass.
## Final hygiene
`npm run release:check` passes 391 changed paths including untracked files in the shared workspace; that count includes preserved work outside this task. The separate vault passes `git diff --check`, and all five owned canonical/worklog/index notes pass trailing-whitespace and exact-one-EOF-newline checks. Scoped harness syntax checks pass. No files are staged or committed by this task.
+44
View File
@@ -0,0 +1,44 @@
# Performance check — 2026-10-04
## Findings and changes
- **CPU:** The production CPU profile spent about 925 ms of an eight-second sample inside `filesForContentPack`, which repeatedly scanned and sorted the asset catalog. `src/content/contentManifest.ts` now indexes each immutable file map once through a WeakMap. `src/content/contentManager.ts` caches usable/unavailable pack results until the next content publication; install, removal, validation, and catalog changes invalidate those results.
- **Idle CPU/rendering:** `src/ui/usePresentationClock.ts` stops HUD, aura, and periodic-effect clocks while locally paused, viewing the map/companion, or hidden. Visible shared-online timers still run behind menus. `DungeonRaceHud.tsx` no longer starts its two clocks during ordinary dungeon play. Aura and periodic strips use shallow target-filtered subscriptions, and aura timer effects no longer restart on every new filtered array.
- **Retained RAM:** `src/game/combatAudio.ts` previously retained every decoded cue for the tab lifetime. Its least-recently-used cache now holds at most 64 entries or 32 MiB of decoded PCM. Active playback keeps its own buffer, failed requests can retry, and simultaneous requests share a decode. This limits cache retention, not total browser memory or all in-flight audio allocations.
- **GPU:** Local pause and hidden-page handling already switch the scene to demand rendering and stop physics. The checked stable pause/background samples made zero WebGL draw calls. No rendering-resolution, texture-quality, lighting, or culling reductions were applied.
## Measurements
Production builds were exercised in headless Edge on the AMD Radeon RX 9070 XT, at a 1280 × 800 viewport with a 1920 × 1200 drawing buffer. `scripts/playtest-performance.cjs` creates a temporary local account/database, enters Wailing Caverns, samples entry combat and movement, pauses/resumes, exercises synthetic page visibility, and repeats entry/exit three times. The historical label `active-idle` means no player input; companions may fight during that sample.
| Sample | Before | After | Confirmation |
|---|---:|---:|---:|
| Entry main-thread busy time | 77.7% | 44.2% | 61.7% |
| Movement-following main-thread busy time | 79.2% | 69.0% | 55.6% |
| Stable 20-second pause busy time | 5.41% | 0.49% | 0.46% |
| Stable pause draw calls/second | 0 | 0 | 0 |
| Stable pause JavaScript heap | 192.57 MiB | 173.26 MiB | 173.06 MiB |
| Third menu return JavaScript heap | 59.88 MiB | 59.59 MiB | 59.71 MiB |
These are individual route samples, not a deterministic combat benchmark. NPC appearances, combat progression, loaded effects, and concurrent project work vary. The heap difference and active draw-call difference must not be attributed entirely to these fixes. The CPU profile confirms removal of the catalog-scan hotspot; the stable pause results reproduce.
A separate lookup benchmark used the same 21,585-file manifest and 2,181-file combat-media pack for both algorithms: 100 old scan/sort lookups took 746.06 ms; building the new index took 14.91 ms once; 100 cached lookups took 0.023 ms. This isolates the catalog work, not total game speed.
The after run's synthetic hidden sample used 0.43% main-thread busy time and zero draw calls; resume restored continuous rendering. JavaScript heap changed only 0.024 MiB over its stable paused 20-second window. Menu returns released most scene heap and backing storage across three cycles. No page errors occurred.
One third-cycle pause window still showed a transient workload (12.3% main-thread busy time and 226 draw calls/second) despite demand mode. The confirmation run adds a paused CPU profile and an additional 20-second settled sample: both its initial third-cycle pause and settled window had zero draws and no layouts. The settled window used 0.50% main-thread busy time; the paused profile was overwhelmingly idle. This transient was not reproduced or conclusively attributed, so pause is not certified draw-free during every asynchronous transition.
## Validation and evidence
- 59 focused tests passed across content validation/persistence, audio, aura/periodic displays, scene lifecycle, and disposal. Audio regressions specifically cover byte/count eviction, concurrent reuse, failure retry, and active playback.
- `npm run build` passed TypeScript, Vite, KTX2 and environment-performance audits, compression, and release-bundle verification. The environment audit reported zero regressions across 106 assets, with 60 existing budget exceptions.
- `npm run release:check` passed. An initial attempt raced another task writing a document; the subsequent check passed without changing unrelated work.
- Visually inspected active and resumed gameplay and pause screenshots: character bodies, starter gear, enemies, effects, and HUD remained visible. This is not an equipment or character-rendering change.
- Raw measurements/screenshots/profiles: `artifacts/playtest/performance/before/`, `after/`, and `confirmation/`. Each run contains `results.json`, `renderer.json`, active/pause screenshots, and `active.cpuprofile`; confirmation also contains `paused.cpuprofile`.
- Repeat with `node scripts/playtest-performance.cjs`; set `PERFORMANCE_LABEL` for a separate evidence directory. `PERFORMANCE_URL` can point the same UI route at a local preview, but production mode is necessary to exercise installed-content checks.
## Limits and remaining targets
Main-thread busy percentage is one browser thread's occupied wall time, not whole-system CPU utilization. Forced-GC JavaScript heap and backing storage are not total process RAM, GPU memory, or VRAM. Draw calls measure submitted rendering work, not device GPU utilization. Hidden-page handling is a synthetic lifecycle test, not an OS background-throttling measurement.
This pass covers one desktop dungeon route, not every dungeon, mobile/Android, high-shadow/resolution settings, shared-online play, or a long combat soak. Original-effect graph and texture caches still retain distinct media for the tab lifetime; safe eviction needs a separate varied-spell/resource-ownership check. Remaining active profile cost is primarily rendering, transforms, and layout; larger scene and device-level GPU profiles should guide any further visual changes.
+33
View File
@@ -0,0 +1,33 @@
# RoM Barren Caves final-build clear — 2026-10-04
## Outcome
The final build completed Barren Caves through ordinary WASD movement and combat inputs. The native results overlay displayed **FULL DUNGEON CLEAR**, “Barren Caves complete,” and **Anglo — Defeated**, with **Bosses defeated 1/1** and a displayed time to final boss of **13:36.9**. The overlay screenshot and route telemetry agree: Anglo is recorded as a level-36 boss at 0/152,256 health and dead. The overlay appeared during movement at checkpoint 358/376, after 697.18 m of 717.47 m expected route distance; native completion ended movement, so the clear claim is based on the result UI and dead-boss state, not arrival at the guide endpoint.
## Run and build
- Harness: `node scripts/playtest-campaign-boss-pass.cjs --route=barren-caves --campaign-boss-route --max-campaign-seconds=1200`. Source confirms `--max-campaign-seconds` sets the campaign route deadline; `--route-seconds` applies to the non-campaign route path.
- Final-build assets reported by the build: `index-DHQInfBp.js`, `combatStore-Cx06rFzG.js`, `GameRuntime-D87YWi4Q.js`, `PartyFrames-5jlxKxxK.js`, `romAbilityCatalog-CHjYfHkT.js`, and `index-499eecpe.css`. The harness result manifest confirms it loaded the final `index`, `combatStore`, `GameRuntime`, and `PartyFrames` JavaScript modules.
- The harness created an isolated level-30 UI profile, Routepriestq (RoM Priest / Knight, healer), and isolated temporary browser/server data. Level gains to 36 were natural XP. No readiness promotion or loot action occurred.
- Navigation used normal WASD inputs for 728 movement steps. 358 of 376 checkpoints were verified before the native result overlay stopped input. The final overlay remained visible in the captured boss-arrival screenshot.
## Party, healing, and combat
The player survived at level 36 with 2,629.45/2,629.45 health. Caelan survived at 1,190.57/2,909.79, Durnik at 2,289.97/2,289.97, and Veyra at 2,343.25/3,081.81. Rorik was the sole party death at 0/3,679.16. The healer produced **46,328.47 effective healing** across 79 healing actions with a positive healing event (97 healing inputs were recorded). The harness observed 41 distinct dead mobs, including Anglo.
The largest player-to-living-party-member separation in periodic route samples was 12.52 m. The largest recorded after-pack-consolidation separation was 15.56 m. The boss was killed by natural party combat; the harness’s direct boss-fight handler did not run because the native full-clear overlay had already appeared.
## Visual and error review
Inspected the roster desktop and compact screenshots, entrance, 25%, 50%, 75%, and boss-arrival screenshots. Character bodies and held starter weapon silhouettes (staff, shield, and other weapons) remain visible in the roster and dungeon. Clothing is sparse on the displayed avatars. The full-clear panel is legible over the dimmed dungeon and names Anglo as defeated.
The route record has no page or console errors. The aggregate run manifest contains one `favicon.ico` 404 request from the local server; no gameplay or rendering error was recorded.
## Evidence
All evidence is under `playtest-artifacts/campaign-boss-pass/final-build-20261004-150021873/`:
- `barren-caves-campaign-boss-route.json` — native overlay state, dead-boss record, navigation, party health, healing, and observed combat events.
- `results.json` — final build module list, profile, and run-level browser errors.
- `barren-caves-boss-arrival.png` — visible FULL DUNGEON CLEAR panel and Anglo 1/1 result.
- `roster-priest-knight-level-30.png`, `roster-compact-priest-knight-level-30.png`, `barren-caves-entrance.png`, `barren-caves-boss-route-25.png`, `barren-caves-boss-route-50.png`, and `barren-caves-boss-route-75.png` — roster/body/gear and dungeon visual checks.
+49
View File
@@ -0,0 +1,49 @@
# Campaign boss-route playtest — 2026-10-04
## Scope and method
This pass extends ordinary campaign routes in Barren Caves and Wailing Caverns through their normal boss encounters. The scoped Playwright harness creates an isolated Priest through the UI, prepares the permitted level-30/Knight-secondary fixture before entry, enters the normal Dungeons picker as healer, and uses ordinary WASD, Tab, F1/F2, party-frame selection, learned bound abilities, and the visible resurrection control. Source navigation paths are movement guidance only. During the live campaign, game position, health, mana, cooldowns, combat targets, party positions, and dungeon results are read-only telemetry; no loot was taken and no level-cap or readiness setting was changed.
The final build was produced by the parent task after Killing Spree and native-combat implementation. Its asset names were `index-DHQInfBp.js`, `combatStore-Cx06rFzG.js`, `GameRuntime-D87YWi4Q.js`, `PartyFrames-5jlxKxxK.js`, `romAbilityCatalog-CHjYfHkT.js`, and `index-499eecpe.css`. Earlier route observations are explicitly labeled current-dist exploration and are not substituted for final-build verification.
## Barren Caves: final-build full clear
The final-build ordinary campaign run completed the dungeon. The native result screen reads **FULL DUNGEON CLEAR**, **Barren Caves complete**, **Anglo — Defeated**, and **Bosses defeated 1/1**; time to the final boss was **13:36.9**. The visual proof is [barren-caves-boss-arrival.png](../playtest-artifacts/campaign-boss-pass/final-build-20261004-150021873/barren-caves-boss-arrival.png), and the read-only route/result record is [barren-caves-campaign-boss-route.json](../playtest-artifacts/campaign-boss-pass/final-build-20261004-150021873/barren-caves-campaign-boss-route.json). Build assets observed by this route harness were `index-DHQInfBp.js`, `combatStore-Cx06rFzG.js`, `GameRuntime-D87YWi4Q.js`, and `PartyFrames-5jlxKxxK.js`.
The run followed normal source-geometry checkpoints for 697.18 m of the 717.47 m guide and verified 358 of 376 vertices. The final boss and party defeated the encounter before the next checkpoint, which opened the native results screen; the driver then stopped without crossing the overlay. The character advanced from level 30 to 36 through normal encounter experience. Rorik was dead at the result screen; the healer and the other three companions were alive. No loot or results-screen action was taken.
## Barren Caves: current-dist exploration clear
An ordinary, navigation-guided Barren Caves run advanced through the cave and reached the native dungeon-results screen. The screen states **FULL DUNGEON CLEAR**, **Barren Caves complete**, and **Anglo — Defeated** with **Bosses defeated 1/1**; it reports **10:45.8** to the final boss. The run had explored 625 m. The visible result screen is [failure.png](../playtest-artifacts/campaign-boss-pass/exploration-current-dist-20261004-144223345/failure.png); the captured page text and Playwright error are in [failure.json](../playtest-artifacts/campaign-boss-pass/exploration-current-dist-20261004-144223345/failure.json). The 25%, 50%, and 75% ordinary-traversal images are [route 25](../playtest-artifacts/campaign-boss-pass/exploration-current-dist-20261004-144223345/barren-caves-boss-route-25.png), [route 50](../playtest-artifacts/campaign-boss-pass/exploration-current-dist-20261004-144223345/barren-caves-boss-route-50.png), and [route 75](../playtest-artifacts/campaign-boss-pass/exploration-current-dist-20261004-144223345/barren-caves-boss-route-75.png). The normal profile and entry images are [roster](../playtest-artifacts/campaign-boss-pass/exploration-current-dist-20261004-144223345/roster-priest-knight-level-30.png) and [entrance](../playtest-artifacts/campaign-boss-pass/exploration-current-dist-20261004-144223345/barren-caves-entrance.png).
The harness process exited with a locator timeout after the game had opened the result overlay: a follow-up heal tried to click Caelan's party frame, and the overlay correctly intercepted it. That is a post-clear harness error; it does not negate the visible native clear result. Rorik was dead in the result-screen party snapshot, while the other three companions and the healer were alive. The run received normal combat experience and the character advanced to level 36. No result-screen button was clicked and no loot was collected.
This exploratory result is not a final-build clearance claim. A separate final-build rerun uses the corrected overlay guard and stricter navigation driver.
## Wailing Caverns: exploration and route-driver correction
The earlier current-dist source-guided attempt covered 539.9 m against a 432.4 m planned path and reached 107 of 120 coarse checkpoints. Between checkpoints 106 and 107 the player dropped from about y=-90 to y=-106, ended in Kresh's Grotto, and remained 46.3 m from Lady Anacondra. The party remained together and alive, but the boss was not reached and Vow was not attempted. [The endpoint image](../playtest-artifacts/campaign-boss-pass/exploration-current-dist-20261004-143155433/wailing-caverns-boss-arrival.png) shows the lower water channel below the intended upper route.
This result is classified as a route-driver divergence, not a demonstrated dungeon navigation defect. The earlier driver used 3.8 m checkpoint spacing, a 4.4 m acceptance radius, 850 ms movement pulses, and could skip a blocked nearby guide point. Those tolerances could cut narrow corners or cross a ledge. The scoped harness now preserves each source-path vertex, accepts a checkpoint only within 0.65 m, scales short ordinary-input pulses from observed movement speed, records a large live downward displacement, and stops instead of skipping a geometry or elevation point. A tight source-navigation path is required before the Wailing conclusion can be updated.
The current profile exposes Vow of Silence (DBC spell 490275) at level 30. The final campaign identity had Vow assigned through the in-game Ability Controls UI to the secondary R1 input (Shift+5); the final-build binding image is [wailing-caverns-vow-binding-ui.png](../playtest-artifacts/campaign-boss-pass/final-build-20261004-151549020/wailing-caverns-vow-binding-ui.png). The route did not press Vow. The separate live Boss Testing result is recorded below. The corrected pinned source says Lady's listed Fear/Horror immunity does not imply Silence or Interrupt immunity.
## Wailing Caverns: final-build route and Lady encounter
The strict final-build route reached Lady Anacondra's Lair through ordinary movement input. It covered 433.96 m in the game and 430.14 m from input-step measurements against a 432.42 m navigation guide, verifying all 284 source vertices in 505 movement steps. No route block, downward-drop stop, player death, or party death was recorded; the endpoint snapshot and screenshots show all five party members alive. The route images are [route 100](../playtest-artifacts/campaign-boss-pass/final-build-20261004-151549020/wailing-caverns-boss-route-100.png) and [arrival](../playtest-artifacts/campaign-boss-pass/final-build-20261004-151549020/wailing-caverns-boss-arrival.png). They show the party, equipped staff/bow/shield silhouettes, and live Deviate Guardians in the lair.
Lady Anacondra did spawn and fight during approach combat. The event stream records her source-scheduled `spell-9532` damage against the party. A normal player Rising Tide action selected `lady-anacondra-spawn` at 1791127452078 with 2,942 HP remaining. While that cast was resolving, captured party damage events landed for 346, 166, and 2,430 effective damage, totaling exactly the remaining 2,942 HP. The action then returned **“That target is already defeated.”** This confirms the first-boss fight and its defeat during route stabilization. No Vow input was attempted in this campaign run.
The captured JSON's endpoint-only `bossFight` classifier says `boss-not-reached-or-not-loaded`; Lady is absent from `sceneMobsAtEndpoint`. Earlier periodic snapshots showed her full HP but did not sample her death during the blocking pack handler. The absence-only classifier misses the selected-target error and matching combat sequence, so it is not used as defeat evidence. The run did not capture `dungeonRunStore` objective progress or open the native full-clear overlay, so Wailing Caverns full clear is **unverified**. After Lady, the listed objectives include Kresh, Lord Cobrahn, Lord Pythas, Skum, Lord Serpentis, Verdan the Everliving, and event-only Mutanus; these were not covered. The five nearby-pack records also have empty initial engagement and zero heals/damage, so their `pack-cleared` labels are invalid and are not credited as kills.
At the original clipped-camera reference position, the live route reached a nearby pose 5.18 m away. Held right-drag changed yaw by -0.378 rad; after release and a further unheld mouse move yaw changed by 0 rad. Pointer lock remained null throughout and player movement stayed under 0.001 m per probe. The three inspected images are [before drag](../playtest-artifacts/campaign-boss-pass/final-build-20261004-151549020/wailing-caverns-original-clipped-endpoint-before-right-drag.png), [after drag](../playtest-artifacts/campaign-boss-pass/final-build-20261004-151549020/wailing-caverns-original-clipped-endpoint-after-right-drag.png), and [after unheld move](../playtest-artifacts/campaign-boss-pass/final-build-20261004-151549020/wailing-caverns-original-clipped-endpoint-after-unheld-move.png). This reproduces camera input recovery at a nearby pose; it does not establish visibility from every cave angle. A second check at the Lady route endpoint also rotated by -0.378 rad and remained stable after release.
The route used final production assets `index-DHQInfBp.js`, `combatStore-Cx06rFzG.js`, `GameRuntime-D87YWi4Q.js`, and `PartyFrames-5jlxKxxK.js`. The full capture is [wailing-caverns-campaign-boss-route.json](../playtest-artifacts/campaign-boss-pass/final-build-20261004-151549020/wailing-caverns-campaign-boss-route.json). A follow-on harness edit now probes the loaded Zustand exports for read-only `dungeonRunStore` progress and records it when exposed. That addition was not exercised by this completed capture; the next route record must confirm whether the current bundle exposes that store to the harness.
## Separate pre-correction live Vow of Silence diagnostic
The normal Boss Testing run in [ROM_VOW_LIVE_FINAL_2026-10-04.md](ROM_VOW_LIVE_FINAL_2026-10-04.md) is separate from campaign coverage. A UI-bound Priest used Vow on Lady's actual scheduled `spell-9532` Lightning Bolt; the cast ended before its scheduled completion and an 8.4-second Silence status applied. Nature Thorns (`spell-8148`) nevertheless became active during that status window, so Vow's full spell suppression is not certified. Lady was defeated in that diagnostic and all five party members survived, but it is not evidence of a Wailing campaign clear. The report includes the cast-timeline screenshots, raw event details, and the precise silence-gating limitation.
That pre-correction result is preserved under `playtest-artifacts/vow-live-final/final-build-20261004-151736260/`. The parent subsequently corrects the source-driven silence selector and rebuilds. The independent replay in `final-build-20261004-153859692/` on `index-Cm7Olrd2.js` / `combatStore-1B4KOVhS.js` passes real Bolt cancellation, no nonphysical cast during the full 8,400-ms status, and natural Nature Bolt resumption 61 ms after expiry. Lady is defeated with five actors alive. This later counter check supersedes the suppression failure; it does not replace the campaign route's earlier asset set or certify a full Wailing clear.
The campaign harness and report use the final production assets without building or changing game runtime source. No loot was taken and no readiness or level-cap setting was changed.
+43
View File
@@ -0,0 +1,43 @@
# Native RoM five mechanics gap audit — 2026-10-04
## Scope and finding
This targeted audit covers the five active native identities in `src/game/romFiveIdentities.ts`: Knight/Scout Rorik, Priest/Knight Serenna, Rogue/Warrior Veyra, Scout/Rogue Caelan, and Mage/Warlock Durnik. It checks the current source inputs and translated effects that reach player and owned-NPC casts, including pair skills actually learnable in each selected build. RoM remains **Needs Testing**; this is not a readiness or balance promotion.
The native stat and combat-input route already contains the active-five source channels audited in the recent native passes, including HolyHeal and supported DotMagic healing, player/owned-NPC source bindings, training weapons, Throw, ShieldDef, native target resolution, and target-count falloff. This review found one concrete pair-effect mismatch: the Knight/Scout Blood Punishment rider still used RuneWaker's old 50% healing-received child and omitted current CoA's Light damage bonus. It now modifies the caster's existing Blood Arrow aura with current CoA's 15% healing-received and 5% Light/Holy damage benefits. No separate passive timer is created.
The owned-party direct-damage adapter also had two gaps that hid this rider in NPC play: it inferred native RoM damage school from presentation text, and its deterministic no-roll direct path did not resolve generic caster damage auras. The party path now uses `abilityDamageSchool` for Runewaker spells and prefers an explicit direct effect school when the translated source component supplies one; the ability-level school remains the fallback. This preserves source-described mixed packets without inferring from presentation text, while WoW school selection is unchanged. Active actor-owned damage modifiers are also applied to no-roll Runewaker direct hits. The dedicated Holy Blessing modifier is excluded from that generic pass because Priest already resolves it through its source-specific path. Native `statRuleset: "rom"` hits continue through the existing combat-roll resolver.
## Blood Punishment source and identity
The generated RuneWaker catalog identifies `499923` as the learnable level-70 Knight/Scout passive Blood Punishment, with prerequisite Blood Arrow `490420`; the source general active is Scout Blood Arrow `490434` (the current CoA corresponding active is `499924`). The local `learnmagic.db`, `magiccollectobject.db`, and `magicobject.db` SHA-256 values match the generated catalog's recorded hashes. Local component `623167` links its one-second `onTimeMagicId` to `499910` for 30 seconds. `Resource/luascript/02708.lua`'s `lua_bico_kni_sco_es_70_blood` checks owner buff `500871` and cancels `623167` when that Blood Arrow marker is absent; this is the old child-timer behavior.
The [current CoA Knight/Scout skill list](https://chroniclesofarcadia.com/wiki/skills/list/6-2?locale=ar) still identifies Blood Punishment as Knight/Scout `499923`, Blood Arrow as Scout-general `499924`, and says Blood Arrow increases received healing by 15% and Light damage by 5% while active. The [10.2.1 patch history](https://chroniclesofarcadia.com/ru/patch/10-2-1-1000) records the 15% healing-received reduction for Knight/Scout Blood Punishment. The exact skill IDs and pairing match the local rows, so the current CoA rider replaces the stale local 50%/30-second child. The current page's level-105 examples do not change Healer Man's level-80 cap.
The runtime attaches both modifiers directly to the existing caster aura tagged `rom-self-health-drain` for source skill `490434`. That keeps the rider actor-local and ties its lifetime to the active Blood Arrow aura already modeled by the simulator. The simulator still models the local Blood Arrow 30-second lifetime and 3%-of-maximum-HP periodic cost; this change does not claim full CoA toggle, deactivation cooldown, or per-ranged-skill HP/MP cost parity. The local 623167/499910 timer is intentionally not layered onto the current CoA rider.
### Remaining Blood Arrow base-contract work
This is source-confirmed but intentionally not implemented by the passive-rider fix. The [current CoA Scout general-skill page](https://chroniclesofarcadia.com/en/wiki/skills/list/1-2) documents Blood Arrow as a toggle that can be deactivated from its buff, with a ten-second cooldown on deactivation and an HP cost on physical attacks. The [10.7.0 patch notes](https://chroniclesofarcadia.com/en/patch/10-7-0-1000) also describe Blood Arrow's per-tick Mana cost. Healer Man's local `490434` instead remains a 30-second aura with a 3%-maximum-HP periodic drain and has no player toggle, deactivation cooldown, or per-offensive-attack HP/Mana charge. The general page's Blood Arrow row is `499998`, while the Knight/Scout page identifies the passive's active as `499924`; this audit does not assume those rows have an identical current cost contract. Keep these base-skill differences open and do not describe the pair as fully CoA-parity complete.
## Other active identities
| Identity | Audit result |
|---|---|
| Priest/Knight | The selected pair rows and Devotion Halo are covered by the pair catalog/runtime checks, and the native healing bindings cover supported direct and periodic HolyHeal for player and owned-NPC casts. The source-only Holy Chains child has no measurable player-facing payload; no runtime value is inferred. No comparable source-confirmed player/NPC effect mismatch was found. |
| Rogue/Warrior | The selected pair effects are covered by the pair tests, including Throwing Mastery, Whirlwind Mastery, Poison Spray, Toxic Splash, and Poison Battle Technique. One-Hand Axe Mastery is inapplicable to the canonical dual-dagger loadout. The source does not supply usable values for remaining Reclaim Energy/Toxic Splash chance clauses, so they remain source-blocked instead of guessed. No comparable confirmed mismatch was established. |
| Scout/Rogue | The pair and Poison Powder routes are covered by helper/runtime tests. `docs/ROM_SCOUT_REMAINING_PASSIVES_AUDIT.md` keeps Exploiting Shot's unspecified stack cap and Deadly Counterattack's missing critical amount/buff duration separate from existing hybrid estimates. These are unresolved source values, not confirmed values that this pass could implement. |
| Mage/Warlock | The ten exact pair skills and source-selected cast/proc branches are covered by pair tests. The Flame Follower identity conflict concerns Mage/Warden, outside Durnik's pairing; it is not mapped by ID alone. No comparable source-confirmed player/owned-NPC mismatch was found for this selected pair. |
| Knight/Scout | Current CoA Blood Punishment was the concrete confirmed mismatch fixed here. Its stale local child timer is not layered onto the current aura rider. The separate base Blood Arrow toggle/cost contract above remains open. |
The old Knight/Scout Lua callback is source-confirmed, but current CoA reuses the exact passive identity and publishes replacement benefits. The replacement is attached to the real caster aura, so cancellation and natural expiry remove it with Blood Arrow rather than waiting for a one-second child-timer poll. Unquantified or absent source behaviors remain open rather than receiving guessed values.
## Ownership and verification
- `src/game/romKnightScoutBloodPunishment.ts` owns the exact caster-aura attachment rule.
- `src/game/romPriestKnightOverrides.ts` owns the learned Knight/Scout passive marker and routes Blood Arrow activation through the helper.
- `src/game/partyRuntime.ts` resolves RoM direct-hit schools from source ability effects and applies active actor-owned damage modifiers in the no-roll Runewaker compatibility path, excluding Holy Blessing from its dedicated resolver. Native stat-mode hits use the existing roll-based outgoing-aura path.
- `src/game/romPriestKnightPairValidation.test.ts` covers the exact passive marker, effect values, aura attachment, and player cast. `src/game/romKnightScoutBloodPunishment.integration.test.ts` exercises player Holy and physical damage, healing taken, immediate cancellation, owned-NPC healing and damage, natural expiry, owner isolation, and both native and compatibility stat rulesets. It also checks Earth Chain's explicit Nature school and Wind Arrows' explicit Physical school.
- Focused validation passed: `npx vitest run src/game/romKnightScoutBloodPunishment.integration.test.ts src/game/romPriestKnightPairValidation.test.ts src/game/romScoutRoguePairValidation.test.ts src/game/romMageWarlockPairValidation.test.ts` (154 tests across four files).
No identity, stat input, health, loot, recovery, level-cap, or readiness scope changed.
+107
View File
@@ -0,0 +1,107 @@
# RoM five-class native scaling and automatic training gear
This targeted check covers Knight/Scout, Priest/Knight, Rogue/Warrior, Scout/Rogue, and Mage/Warlock using the named, race-legal identities in `src/game/romFiveIdentities.ts`. It does not promote RoM builds to Ready or introduce loot progression.
Other RoM classes remain available in the existing generated companion pools; this diagnostic verifies the five requested active identities and does not certify the other classes.
## Character and NPC growth
Player and companion native stats use `deriveCharacterStats(..., "rom")`, dispatched to `romNativeStats.ts`. Class-specific source growth starts from the level-one attributes and repeats `value = value * (1 + percentGrowth) + flatGrowth` for each additional level. Native HP/MP and attack/defense then use the source coefficients for the primary class, equipment, and active modifiers. Wisdom is a distinct attribute rather than WoW Spirit.
The shared simulator activates each named identity's secondary class at level 10 and treats its level as the primary level. The secondary contributes one tenth of its base growth; it does not supply a second complete equipment set. Native secondary Mana pools retain sourced MP, while the primary kit keeps its existing Mana/Energy/Focus resource behavior.
Generated RoM companions explicitly select native stats. A configured companion must explicitly pass `statRuleset: "rom"`; omission deliberately retains legacy WoW compatibility. Existing saved RoM players also retain WoW compatibility unless switched. A player's selector does not overwrite every NPC's independently owned mode.
The follow-up fixed two real NPC inconsistencies. `partyStore.ts` now normalizes creation and level synchronization to the shared finite 1–80 range before deriving gear, talents, resources, and secondary unlocks. Native full HP/MP remain equal to their precise caps after creation, leveling, and gear rescaling; wounded pools stay bounded and defeated actors remain at zero. `combatActorAuthority.ts` now refreshes a native companion's health cap when its source changes, preserving the live actor's precise health fraction. Previously a level-up could calculate the new member cap correctly, then actor synchronization could overwrite it with the old cap. Unchanged source/caps retain the authoritative live actor values.
## Gear without loot progression
RoM has no authentic heirloom item system. The simulator's shared equipment fallback supplies **automatic training gear** for the player and party, using compact ordinary RoM source-item references for these five classes. It fills eligible empty slots and recalculates the reference values with level, class, role, and applicable secondary weapon unlocks. Reference selection and interpolation are simulator policies, not the original game's acquisition or item-upgrade rules.
The existing player and NPC reference curve caps at level 79. At level 80, character base growth continues but automatic gear retains its level-79 values. This avoids inventing unsupported endgame gear. The same cap applies to both owners. Native item payloads are counted once; their accompanying WoW projection is ignored in native derivation.
Training references are not a smooth linear curve: source-item tiers/package anchors and weapon-slot unlocks cause step changes. An exact levels-1–80 audit found byte-identical player/NPC payloads; one example is Rogue's 65→66 increase of 912 DMG and 362 MDMG. At 79→80 all five classes' gear DMG/MDMG stay unchanged while base ATK/MATK grow. In this source version both attack-to-damage coefficients are zero, so gearless native DMG/MDMG are zero; the current shared adapter and ability resolver are still separate from complete native combat.
RoM fallback item names, source labels, and Equipment slot labels now say Training rather than Heirloom. Stable item IDs, internal quality, stat budgets, saved inventory, and assignment behavior are unchanged. The panel explains the level-79 cap. No loot source, drop table, boss reward, card reward, or loot distribution behavior is changed by this work.
## Enemy health
`src/scene/MobPopulation.tsx` passes both `contentCategoryForClass(player.classId)` and the player's selected ruleset to `dungeonEnemyScaling`. The original curve below follows character origin. The later native-only factor applies only to RoM-origin characters using native `rom` stats; RoM characters in legacy WoW-stat mode continue to use the original curve. Dungeon source, NPC composition, and party size do not select the health policy.
Ordinary enemy level follows player level, clamped to 1–80. Bosses have a one-level edge, also capped at 80. Before authored multipliers, RoM trash HP is:
```text
round((1000 + 100 * L + 8 * L²) * (1 - max(0, L - 70) * 0.006))
```
Campaign bosses use the larger of authored baseline HP and a separate durability floor:
```text
max(trashBaseline(L) * authoredHealthMultiplier,
(1000 + 1200 * L + 22 * L²) * 1.15 * finaleOrSliceFactor)
```
Normal campaign finales use a 1.9 floor factor unless explicitly marked standard. Boss difficulty and any per-boss balance override then multiply the result. Bite-Sized bosses instead use their existing interpolated floor/ceiling bands. Boss Testing has its own reduced floor and may use source creature health. Manastorm follows its separate enemy modifiers. Dungeon difficulty chooses encounter/population variants; it is not an extra generic HP multiplier in this helper.
| Player level | RoM trash baseline HP | Boss level | Standard campaign boss floor HP |
|---|---:|---:|---:|
| 1 | 1,108 | 2 | 4,011.2 |
| 10 | 2,800 | 11 | 19,391.3 |
| 30 | 11,200 | 31 | 68,243.3 |
| 60 | 35,800 | 61 | 179,471.3 |
| 79 | 55,651 | 80 | 273,470 |
| 80 | 56,588 | 80 | 273,470 |
These are the legacy WoW-stat baseline/floor examples, not every creature's final HP. The historical curve predates native character stats; the current native-only adjustment is separately anchored and measured below. Neither calculation claims a RuneWaker source mob-HP formula.
Anglo in full Barren Caves has an authored health multiplier of 7.5 and is the default finale, so its 1.9 finale floor dominates from level 10 onward. Its actual registered legacy-mode HP at player levels 1/10/30/60/79/80 is **9,240 / 36,843 / 129,662 / 340,995 / 519,593 / 519,593**; native-stat RoM multiplies those results by the separate boss anchor below. The older `romDungeonHealth.test.ts` boss sample manually used baseline × 7.5 rather than the actual finale floor, and initialized a compatibility-mode player with seed-selected native NPCs. That older sample is not evidence for a complete native five-identity party or the current real boss pool.
The earlier fixed-cohort diagnostic is historical: it rotates five identities into the player seat, explicitly creates four native companions, and samples one real Barren Caves trash spawn and Anglo at six levels (60 samples) while omitting active player damage except Priest healing. It predates exact-pair active player rotations and native-only HP pacing, so its timings and defeats are not current outcomes.
| Player level | Trash kill-time range | Anglo kill-time range |
|---|---:|---:|
| 1 | 15–18 s | 148.2–232.3 s |
| 10 | 4.5–7 s | 82–105 s |
| 30 | 10.5–12 s | 78–118.3 s |
| 60 | 7.6–11 s | 49.5–72 s |
| 79–80 | 2.5–5.4 s | 25–37.5 s |
The saved earlier report records that all 60 samples completed; trash had no companion defeats, while level-one Anglo caused companion defeats in four of five variants. This was a conservative diagnostic because active player damage was omitted, not a balance certification or automatic tuning target. Its 240-second level-one boss limit and 120-second limit elsewhere differ from the current fixed-rotation report.
## Evidence
- `src/game/romFiveNativeDungeonHealth.test.ts`: three targeted checks passed, including the actual RoM/WoW dungeon HP helper, all 60 fixed-cohort samples, and boss difficulty/finale behavior. Diagnostic report: `artifacts/rom-five-native-dungeon-health/report.json` (ignored local artifact).
- Exact gear audit: player/NPC owner contexts match for all five identities; reference payloads were inspected across levels 1–80, including weapon unlocks, source tier jumps, and the 79→80 plateau.
- The final focused selection passed 176 tests across 12 files. The new parity suite contributes 13 cases covering the five native player/generated/configured identities at levels 1/9/10/30/60/79/80, explicit-versus-omitted rulesets, secondary unlock, level bounds, owner gear parity, and full/wounded/defeated pools. Existing native aura/channel/periodic integration and legacy compatibility regression tests also passed.
- Ready-five passed seven Vitest and twenty Python checks. Avatar validation passed 47 tests across ten files before the subsequent party-only fixes; avatar and numerical gear code were not changed afterward.
- Final full production build passed: 8 GB TypeScript project compilation, Vite (1,266 modules), asset audits, compression, and release-bundle verification (5,562 objects, 123 chunks). Final chunks: `index-Ba_szGct.js`, `combatStore-BGky6YlB.js`, and `PartyFrames-CcpsvWIi.js`.
- Exact-final-dist Edge QA passed through `scripts/playtest-stat-rulesets.cjs` using an isolated temporary account/server. Native player tabs, a native Mage NPC owner panel, Training gear labels/cap note, legacy migration, independent saved mode choices, switching locks, build/checkpoint preservation, both native/compatibility dungeon runs, and desktop/390-pixel layouts passed. Visual review confirmed roster and both dungeon character bodies with held starter staffs and closed overlays/loading intro.
- Browser report: `playtest-artifacts/stat-rulesets/results.json` and its eleven referenced screenshots, including `05-native-npc-stats-attributes-desktop.png`. Diagnostics had zero page errors, HTTP failures, or failed application-resource responses; the only console response error was the optional favicon 404. Synthetic legacy/combat/online fixtures remain guard checks, not a live multiplayer certification.
- Final `npm run release:check` and `git diff --check` passed, including untracked files and concurrent unrelated work. The four owned vault notes passed whitespace/EOF checks. The prior stat-ruleset validation remains in `docs/CHARACTER_STAT_RULESETS_2026-10-03.md`; its 219-test result is historical and is not added again to this task's totals. This follow-up supersedes the browser artifacts stored at the shared stat-rulesets path.
## Player-rotation combat follow-up (2026-10-03)
The final diagnostic in `src/game/romFiveNativeRotation.integration.test.ts` uses the shared `BenchmarkController` rotation and damage path, turns each requested identity into the player in turn, explicitly constructs the other four native identities as companions, and samples one real Barren Caves trash spawn plus Anglo at levels 1/10/30/60/65/66/79/80. This is 80 fixed samples with static actors, not a scene-driven dungeon run; navigation and authored encounter mechanics remain outside its scope.
The post-haste-retention report records 80/80 cleared pulls, zero player or companion deaths, and zero invalid cast failures. Trash took 4.5–9 seconds; Anglo took 39–53.4 seconds. The harness checks for a dead target before making another controller decision. Actors remain fixed in place; no navigation or authored encounter mechanics run.
## Native-only enemy health pacing (2026-10-03)
`dungeonEnemyScaling` accepts a selected character stat ruleset with a backwards-compatible default of `"wow"`. Live `MobPopulation` passes the active player ruleset. The added piecewise-linear factor is active only for RoM-origin characters using native `"rom"` stats; it leaves the legacy RoM curve, WoW/CoA-origin results, enemy damage, level, boss difficulty, authored multipliers, and level-80 cap unchanged.
| Character level | Trash scale | Boss scale |
|---:|---:|---:|
| 1 | 0.9 | 0.45 |
| 10 | 1.4 | 0.85 |
| 30 | 1.4 | 0.95 |
| 60 | 1.5 | 1.25 |
| 65 | 1.8 | 1.5 |
| 66 | 2.1 | 1.9 |
| 79 | 3.6 | 2.8 |
| 80 | 3.6 | 2.8 |
Values between anchors interpolate linearly against the clamped 1–80 character level. This is a simulator health budget measured against the five canonical training builds, not a source RuneWaker mob-health formula. The general generated companion pool and RoM readiness state are unchanged.
`artifacts/rom-five-native-rotation/before-native-combat.json` is the earlier 60-sample, six-level comparator; it predates the current native-only health policy and the final eight-level active-rotation matrix. Do not treat it as a paired health-curve experiment. Native amount routing, supported auto-attack channels, limitations, and current verification state are summarized in [`ROM_NATIVE_COMBAT_PASS_2026-10-03.md`](ROM_NATIVE_COMBAT_PASS_2026-10-03.md).
The final production build passed with asset audits, compression, and bundle verification. Exact-dist browser QA passed against `index-D_h9IRuA.js` and `combatStore-BiA9BOQG.js`; 11 screenshots were captured and four representative roster, dungeon, NPC-stat, and 390×844 mobile screenshots were visually reviewed. No page/server errors occurred and the network response-errors list was empty; the only console error was the optional existing favicon 404. A release check passed on 249 paths; the final documentation update requires the final hygiene/release check. The full source hit/defense model and RoM readiness certification remain outside this follow-up.
@@ -0,0 +1,48 @@
# Interrupt, sustain, route and Killing Spree follow-up — 2026-10-04
## Scope
The authorized follow-up checks Vow of Silence against Lady Anacondra, repeated native RoM level-60/80 healing sustain, real Barren Caves/Wailing Caverns route play, and the separate Killing Spree catalog assertion. Work uses Luna subprocesses with extra-high reasoning. The five active native identities remain Needs Testing; the WoW Ready five remain the routine certification scope.
Recovery formulas, automatic Training gear, the level-80 cap, saved-profile compatibility defaults and loot scope are preserved. No readiness promotion, commit or deployment is included.
## Interrupt source correction
The previous Lady Anacondra Vow attempt resolved while the same Lightning Bolt cast remained active. The pinned AzerothCore source uses `CreatureImmunitiesId` as a foreign key, but the importer treated it as a literal signed mechanic mask. Row -93 resolves to Fear and Horror only. The importer now loads the exact pinned immunity table, normalizes its enum-indexed mask into the existing runtime representation, and preserves legacy literal-mask sources. Missing nonzero foreign keys fail closed. Generated Lady creature 3671 now has legacy mask 8388624 and no Silence/Interrupt immunity. The table fixture and SHA-256 make regeneration reproducible; no Vow duration or recovery formula changed.
## Repeated NPC healing sustain
The native Knight player and owned Priest/Rogue/Scout/Mage party complete three consecutive source Anglo pulls at each of levels 60 and 80, with a 10-second rest followed by a 90-second rest. Actor health, Mana, effects and cooldowns continue between pulls. All six pulls clear with no deaths or empty Priest Mana pools.
The owned Priest's level-60 delivered healing is 39,753 effective plus 70,327 overheal (63.9% overheal); level 80 is 183,396 effective plus 80,845 overheal (30.6%). Telemetry separates raw event amounts, delivered healing, actual Mana pools, starting Mana capacity and estimated cast costs. Every observed direct Heal begins on an injured party/player snapshot, even though Regenerate is active. A HoT-only suppression rule was not justified, so healing AI and sourced recovery remain unchanged. This is a six-pull diagnostic rather than every-encounter sustain certification.
## Killing Spree audit
The old representative-signature failure came from requiring a generic non-utility effect for a source periodic-dummy selector. The test now requires Killing Spree's exact `class-script` shape while preserving its dedicated ten-strike player regression. The independently verified child 61851 provides a 20% all-school damage buff for two seconds; the player aura previously used 1% and now uses 20%. All 81 focused Rogue/catalog/rotation tests pass.
Companions still lack the Killing Spree script handler. The player's immediate assaults and deterministic target selection remain approximations. This targeted audit does not promote Combat Rogue or establish full timing/targeting parity.
## Campaign route review
The ordinary roster-to-dungeon entry baseline found the floating Roster details toggle covering Continue at 1440×900. Single-screen roster spacing now reserves a strip below the footer for that toggle; dual-display layouts retain their own spacing. Final normal-click checks at 1440x900 and 390x844 show no overlap; no force click is used in final evidence.
Both original campaign dungeons receive 90-second entrance/encounter segments through normal selection and inputs. Barren Caves records 69.34 units of accumulated travel, 41 keyboard steps, 15 effective player heals (~6,155 healing), seven player damage events (549 damage), and no party deaths. It naturally levels the UI-created Priest/Knight from 30 to 31. The subsequent fixed-level harness assertion stopped that cross-route session; its completed Barren phase remains valid. Wailing Caverns is replayed independently from a fresh level-30 profile and records 23.76 units of travel, 15 keyboard steps, 11 effective player heals (~6,831 healing), three observed entrance enemy deaths, and no party deaths. Wailing records zero player damage, so that segment establishes targeting/healing and companion combat rather than player DPS. Both final phases have no page errors; the logged 404 is identified as favicon.ico.
The first longer Wailing attempt lost its tank after repeated out-of-range heals while the automated healer fell behind. Its trace is preserved. The corrected harness prefers the available ranged spell, reads actual companion positions, follows wounded allies into healing range, waits for casts, and uses normal Attack/Defend inputs to contain the entrance encounter. No game balance or recovery formula was changed to obtain the rerun.
Evidence is in `playtest-artifacts/native-routes/final-20261004-051413627/` (completed Barren phase), `final-20261004-051737171/` (failed Wailing positioning diagnostic), and `final-20261004-052623521/` (corrected Wailing phase). Midpoint and endpoint screenshots were visually inspected. Cave geometry partially occludes endpoint views; these are bounded input/combat checks, not full route, boss-clear, navigation-guide, camera or compact-layout certification.
## Detailed evidence
- `docs/VOW_OF_SILENCE_AUDIT_2026-10-04.md`: source identity, immunity and interrupt investigation.
- `docs/ROM_NATIVE_SUSTAIN_AUDIT_2026-10-04.md`: actor-attributed repeated-pull Mana and healing diagnostics.
- `docs/WOW_KILLING_SPREE_AUDIT_2026-10-04.md`: catalog assertion and dedicated runtime audit.
- `docs/ROM_NATIVE_ROUTE_PLAYTEST_2026-10-04.md`: normal campaign entry, movement, targeting and healing segments, roster hit checks, screenshots and telemetry.
## Validation
Focused checks pass: 40 interrupt/enemy integration cases, five importer cases, 81 Rogue/catalog/rotation cases, and the six-pull sustain diagnostic. The final Vow-only rerun passes eight cases. Ready-five certification remains green with seven Vitest and twenty Python cases. App TypeScript and the production build pass, including asset audits, compression and bundle verification.
The coordinated build contains `index-CAMDJqEC.js`, `combatStore-t7bO4yKt.js`, `GameRuntime-otPnJCWI.js`, `PartyFrames-BsBsesPR.js`, and `index-499eecpe.css`. The CSS includes the roster footer clearance fix. Existing chunk-size/import warnings remain; the asset performance audit reports no new regressions.
Vault diff checks and owned-text hygiene pass for 26 files. The repository release check initially found an extra EOF blank line in the concurrently edited `src/game/benchmark/coaFirstParty.ts`; only that final newline was normalized, preserving its code. Final release hygiene is checked after these documentation edits.
@@ -18,7 +18,7 @@ Generated player source IDs and component payloads are in `src/game/romPlayerCat
| 492935 → 493094 | Component 503192 triggers every 3 seconds for 15 seconds. Child RunPlot is `lua_star_ParryState` in `01129.lua:120`: it checks `EM_RoleValue_ParryState == 1`, checks buff 503192, then adds buff 503200 (Lua argument 20); if 503192 is absent it cancels 503200. Component 503200 carries +5000 Parry and a 15-second lifetime in the local object data. | Current runtime applies +5000 Parry for 15 seconds and consumes it after two blocks; the hidden periodic aura is also kept for 15 seconds. The `EM_RoleValue_ParryState` set/reset condition and the distinct conditional 503200 application are not represented. Prerequisite: map that role-state value and its event lifecycle before wiring the child; do not turn the Lua argument into a duration by assumption. |
| 492939 → 493246 | Component 503207 triggers on timeout after 15 seconds. Child RunPlot calls `lua_Dy_DELAnother(503207, 506236)` in `01129.lua:518`; the helper cancels 506236 only when 503207 is absent. Both local effects are 15 seconds. | No distinct numeric combat payload is added by the child. Natural expiry aligns; early removal of 503207 and the resulting cleanup of 506236 are not represented. Prerequisite: source-backed buff-removal/timeout event semantics if early cancellation is intended to be observable. |
| 493273 → 491614 | Component 502056 triggers on hit. `lua_star_ProtectAlly` in `01129.lua:313` reads last damage, the buff owner, and `BuffInfoType_Power` (`PLV`), removes 502056, then computes `p = 0.40 + 0.008 × PLV`; it casts child 491612 on the original buff owner with `floor(abs(lastDamage × p / (1 − p))) × 2`. | `romPriestKnightOverrides.ts` currently uses a fixed 40% one-hit redirect and +20 Parry. This is an active approximation: it does not preserve the buff's `PLV` or apply the rank-dependent split formula. Prerequisite for a correct shared player/companion implementation: define the source value stored as `BuffInfoType_Power` for 502056, then carry it through the application and incoming-hit paths in `combatStore.ts`/`partyRuntime.ts`; cover it in `romPriestKnightPairValidation.test.ts`. |
| 499923 → 499910 | Component 623167 triggers every second for 30 seconds. `lua_bico_kni_sco_es_70_blood` in `02708.lua:257` cancels 623167 whenever owner buff 500871 is absent. | Knight/Scout passive marker and Blood Arrow's 50% healing-received/30-second effect are represented. The source's early-cancel check when 500871 is removed is not. Prerequisite: observe source-marker removal and cancel the associated aura on player and companion paths; ordinary matching 30-second expiry is already compatible. |
| 499923 → 499910 | Component 623167 triggers every second for 30 seconds. `lua_bico_kni_sco_es_70_blood` in `02708.lua:257` cancels 623167 whenever owner buff 500871 is absent. | Superseded by current CoA's matching Knight/Scout passive: +15% received healing and +5% Light/Holy damage while Blood Arrow is active. `romKnightScoutBloodPunishment.ts` attaches both modifiers to the actual owner Blood Arrow aura, so removal/expiry ends the rider without a separate child timer. The base Blood Arrow still uses the local 30-second, periodic-HP-cost model; CoA toggle/per-ranged-skill cost parity remains open. See `docs/ROM_FIVE_MECHANIC_GAPS_2026-10-04.md`. |
| 495402 → 497924, 497926 | These are timeout children of the old Leopard Instinct active effects. The local `un_495402_cancelbuff` RunPlot in `02383.lua:182` cancels old buffs 620144/620141/620142/620143. | Superseded by current CoA's passive stack behavior. Existing pair handling suppresses the old active effects; do not restore these timeout children. |
| 493047 → 493183 | The local RuneWaker 493183 child is a MagicFunc1 on-hit self-reference. Its tooltip conditions a chance-based +2 Dodge aura on being attacked while Shield of Valor or Shield of Discipline is active, but the numeric chance and required-shield check are absent. | Current CoA's [Knight/Rogue row](https://chroniclesofarcadia.com/wiki/skills/list/6-3?locale=ru) identifies 493047 as Panther's Blessing, a level-45 passive that restores 10% HP and 1% MP on dodge. The exact learned Knight/Rogue passive now triggers from the actual resolved incoming dodge result for the player and generated companions. Because CoA does not publish the percentage bases or a rounding rule, the simulator uses the owner's maximum HP and mana pools and rounds to whole points; each restore is capped to its current pool. The published current row has no rank curve, so these percentages are flat and no scaling is inferred. The legacy 493183 shield-conditioned +2 Dodge child stays suppressed. God's Vengeance is the separate passive 499934. See `docs/ROM_KNIGHT_PANTHER_DODGE_PROC_2026-10-02.md`. |
| 499934 → 499909 | Current CoA's [Knight/Rogue list](https://chroniclesofarcadia.com/wiki/skills/list/6-3?locale=ru) identifies God's Vengeance as passive 499934, level 50: when Punishment hits a target with exactly four Holy Seal stacks, reset Shadow Step. The local LearnMagic row independently makes child 499909 learnable by Knight/Rogue at level 50 with key item 542123 and prerequisite 499934. Its MagicCollect components match general Punishment 490151's payload; 499909 remains a separate child row rather than an alias of general 490151. | The exporter emits this exact linked child as a second level-50 row. It keeps Punishment's direct damage and ordinary Seal consumption, scales the one hit by the pre-spend Seal count, and removes the retired Seal DoT. Player and generated-companion casts reset only the casting actor's learned Shadow Step (490344) when the exact child lands against its own target with four of that target's Seals. A landed combat event is the runtime's “hits” boundary, including full absorbs; miss/dodge, wrong pair, missing passive/Shadow Step, general 490151, and three/five-stack cases do not reset. Companion AI selects 499909 when it is ready and usable, otherwise retaining its general Punishment fallback. |
+78
View File
@@ -0,0 +1,78 @@
# RoM native combat pass — 2026-10-03
The later [native resolution and enemy damage-type pass](ROM_NATIVE_COMBAT_RESOLUTION_PASS_2026-10-03.md) supersedes the open defense/hit/critical, negative DotMagic and special/multi-target input boundaries below. This report preserves the original amount/rotation pass and its historical validation.
## Scope and status
This pass connects source-derived native amount bindings to the existing combat runtime for the five targeted identities: Knight/Scout tank, Priest/Knight healer, Rogue/Warrior damage, Scout/Rogue damage, and Mage/Warlock damage. `romFiveIdentities.ts` remains the identity owner. The new shared-rotation profiles are exact-pair and role gated; they are clean-room heuristics informed by local active catalog data and the add-on source research in [`ROM_ROTATION_ADDON_REFERENCE_2026-10-03.md`](ROM_ROTATION_ADDON_REFERENCE_2026-10-03.md). No add-on code was retrieved or installed. CurseForge's archive request reached a Cloudflare JavaScript/cookie challenge, so the profile priorities are not copied presets.
The native amount routes improve source fidelity, but this is not a full RoM combat port or balance certification. Formula outputs still pass through HealerMan's shared target and combat-state machinery. Unsupported source shapes and gear intentionally retain the documented simulator fallback. The five identities remain targeted-test builds; no readiness or random-pool promotion is included.
## Binding and amount routing
`romAbilityCatalog.ts` attaches `romNativeSource` skill/component identities to supported active RuneWaker ability effects. `romNativeCombatAmount.ts` resolves those identities against the generated source binding catalog. The native route runs from `equipmentAdjustedCombatAmount` only for a RoM stat ruleset and a nonnumeric, source-bound ability amount. It tries the specific direct HolyHeal branch, the supported direct damage branch, and the periodic HolyHeal snapshot. If a source binding or required profile is unsupported, it returns no native amount and the existing shared HealerMan coefficient/scaling path continues.
`romNativeMagicLevel(level)` uses the active character level clamped to the project's 1–80 range as the simulator's effective `MagicProc.MagicLv`; it does not infer skill rank or a separate skill-level curve. The active player-build companion amount routes use the source `player` classification for the native player ExRate branch even though the runtime owner is an NPC actor. This is an explicit simulator choice for the five playable builds, not a claim about every ordinary RoM NPC.
Direct native damage requires a supported source attack component and the active five-class training-weapon profile. Since the stat sidecar has no item provenance or weapon speed, the adapter compares the complete native gear payload with the canonical automatic training profile before reversing its weapon contribution. Unverified/custom gear, unsupported class pairs, missing weapons or component bindings, unsupported calculation types, and unavailable school rates return an unsupported result; the generic shared amount path then remains in force. These gates prevent inferred weapon values from being presented as source-derived.
The direct damage helper returns a positive mean source power after its source target-count factor, before source random range/critical handling and target defenses. Its checked `MagicAttackCalENUM` fixed-type branches support 0, 1, 2, 3, and 5–9; throw, prior-hit, and shield-category inputs remain unsupported because the stat sidecar lacks those exact source values. The consumer also declines components marked `isShareDamage`, or nonzero `decreaseArg` on nonzero `rangeType`, until it receives the runtime target count and ordinal; the pure amount helper can represent those fields but the current consumer does not claim area-damage exactness. Reflected Shot has a regression check for this fail-closed behavior. Supported single-target ability amounts pass to the shared target-resolution path for modeled hit/critical rules, resistance/armor, target auras, absorbs, and damage application. It does not yet resolve every RoM source hit/avoidance point, native defense channel, ability random roll, or source critical-rate field.
`nativeTrainingAutoAttackProfile` provides native physical weapon damage and intervals for player Knight main-hand attacks and NPC Knight/Rogue melee plus Scout Bow attacks; NPC Rogue offhand is also covered. Player Rogue offhand handling requires an explicitly present equipped inventory item. It does not claim a player Scout bow auto profile: player Scout Shot instead uses the native direct-damage route for its actual bound Shoot hit. Caster ambient basic-spell attacks retain their generic interval and MDMG policy. Player melee timing explicitly reads the existing common haste aura via `resolveAuraStatValue` because native stat re-derivation does not populate the shared haste fields; race-affix timing is retained as well. Four focused auto/source-action cases passed: player Knight Main_DMG/source interval with semantic 50% haste, NPC Scout Bow/source clock with ranged haste, NPC Rogue offhand Sec_DMG/offhand clock with melee haste, and a player Scout Shot resolving an actual bound hit. These are source-backed channels, not complete native hit/avoidance, armor, variance, or critical models.
Outgoing damage-over-time still uses native stat inputs through the shared simulator amount path. The source DotMagic snapshot implemented in this pass covers healing; source-native periodic damage remains future work.
## Direct HolyHeal
`romNativeHealing.ts` resolves only source-bound calculation type 5 components. It applies the source MagicLv-adjusted `OrgPower`, clamped `SpellTime`, Wisdom/MND contribution and cap, MDMG coefficient, MPow, HealPowerRate, and HealPoint. The amount adapter samples `RandomRange` at its midpoint (`randomUnit = 0.5`), producing mean source power; the common healing resolver still handles its critical and overheal behavior.
The caster amount is computed without recipient-specific HealAbsorb modifiers. At each real recipient, `nativeHolyHealTargetAmount` applies that target's HealAbsorbRate and HealAbsorb once, unless the component says to ignore absorption. Normal aura/talent and healing-result stages then run through the shared resolver. This prevents the recipient's native absorption stats from being folded into caster power and applied a second time. Group heals resolve each recipient independently.
## Periodic HolyHeal snapshot
Source DotMagic healing is separate from direct HolyHeal. `nativePeriodicHealSnapshot` resolves the supported positive-HP DotMagic component when the buff is applied, using the caster/recipient native stats and the source rank, duration, tick interval, and effective MagicLv. The source rank correction computes a `Buff.Power` snapshot with MND, MDMG, HealPowerRate, HealPoint, the available MPow input, and the target absorption inputs, then stores the integer tick amount. Later ticks reuse the stored value; they do not rerun the caster formula or apply target absorption again.
This follows the observed Runewaker `CalAddDotMagicLv`/`BuffProc` ordering for bound components. The current sidecar exposes neutral all-magic MPow but not every element-specific `BodyRate`, so callers must not read the current default as proof that all elemental healing rates are 1. Unsupported DotMagic shapes return no native snapshot and continue through the existing fallback.
## Rotation and validation boundary
`romFiveRotation.ts` selects only the exact ordered `(statRuleset, primary, secondary, role)` tuple at level 10 or above. It retains learned catalog damage actions as low-priority fillers, while pair-specific guards cover cooldown/setup priority, useful target lifetime, AoE target minimums, emergency healing, current DoT ownership, and near-expiry refresh. The shared evaluator and its party/benchmark callers continue to enforce learned ability, cooldown, resource, range, target, and movement readiness. Pre-secondary levels and unsupported pairs stay on the existing semantic path. WoW keeps its existing specialization selector and is not filtered by the native RoM guards.
`romFiveRotation.integration.test.ts` covers all five exact pairs against active learned catalog abilities, a healthy Priest/Knight using Group Heal for injured allies, Urgent Heal before throughput when an ally is critical, actor/target-owned DoT refresh timing, and the unchanged WoW profile selector. The focused module and integration suites pass together (17 tests).
The fixed full-player diagnostic in `romFiveNativeRotation.integration.test.ts` rotates each of the five identities into the player seat, creates the other four native party members explicitly, and uses the shared BenchmarkController against a real Barren Caves trash spawn and Anglo at levels 1/10/30/60/65/66/79/80. This is 80 fixed samples, not a scene-driven encounter: actor positions are static and encounter mechanics/navigation are not simulated. The test writes `artifacts/rom-five-native-rotation/report.json`. `artifacts/rom-five-native-rotation/before-native-combat.json` preserves the earlier 60-sample, six-level pre-native-combat baseline. The final post-haste-retention report records 80/80 pulls completed, no player or companion deaths, and zero invalid casts. Trash kills were 4.5–9 seconds and Anglo was 39–53.4 seconds. The harness now checks for a dead target before requesting another player decision, so post-kill no-target attempts are not counted.
## Native-only health pacing
`dungeonEnemyScaling` now accepts the selected character stat ruleset, defaulting to `"wow"` for existing callers. Live `MobPopulation` passes the active character's ruleset. A piecewise-linear enemy-health factor applies only when the character source is RoM and the selected ruleset is native `"rom"`; legacy WoW-stat RoM characters retain their prior curve, and WoW/CoA-origin characters are unchanged. The factor changes neither damage nor enemy level, boss difficulty, authored multipliers, or the level-80 cap.
The anchors are indexed by clamped character level and each row is [trash health scale, boss health scale]:
| Level | Trash | Boss |
|---:|---:|---:|
| 1 | 0.9 | 0.45 |
| 10 | 1.4 | 0.85 |
| 30 | 1.4 | 0.95 |
| 60 | 1.5 | 1.25 |
| 65 | 1.8 | 1.5 |
| 66 | 2.1 | 1.9 |
| 79 | 3.6 | 2.8 |
| 80 | 3.6 | 2.8 |
This is a targeted native RoM simulator pacing policy measured against the five requested builds, not a claim about RuneWaker monster-health formulas or full balance. It does not alter loot, progression, source enemy damage, or readiness. The ordinary randomized companion pool is now restricted to the active five identities; the broader configured factory remains available for explicit party construction. This diagnostic's five fixed identities do not promote readiness or broaden the general pool.
## Current verification status
The focused 15-file selection passed 210 tests. Direct and periodic native healing coverage passed 14 player/NPC cases. The source-damage follow-up passed 13 focused tests: seven helper cases, two player/companion amount-parity and Reflected Shot fail-closed cases, and the four actual auto-attack cases above. Ready-five passed seven Vitest and twenty Python checks, avatar validation passed 47 tests, and the final TypeScript project check passed. One separate `combatRotation.test.ts` case still fails its existing unsupported-only `Killing Spree` expectation; that unrelated behavior is unchanged here. The final production build passed with asset audits, compression, and bundle verification. Browser QA passed on the current `index-D_h9IRuA.js` and `combatStore-BiA9BOQG.js` chunks; 11 screenshots were captured and four representative roster, dungeon, NPC-stat, and mobile screenshots were visually reviewed. There were no page/server errors and the network response-errors list was empty; the only console error was the optional existing favicon 404. `npm run release:check` passed on the final 249 changed paths, including untracked files; `git diff --check` passed. The worklog is closed; the unrelated Killing Spree test and remaining source-formula boundaries are documented above.
The fixed diagnostic instantiates only the five requested identities. The ordinary randomized companion pool is now restricted to those five, while the configured factory remains broader for explicit compositions. The pass does not alter the loot path, raise the level cap, change enemy damage, or promote RoM readiness.
## Source and validation references
- Source formulas and archived boundaries: [`ROM_NATIVE_STATS_SOURCE_2026-10-03.md`](ROM_NATIVE_STATS_SOURCE_2026-10-03.md), [`CHARACTER_STAT_RULESETS_2026-10-03.md`](CHARACTER_STAT_RULESETS_2026-10-03.md)
- Exact-pair add-on research and clean-room policy: [`ROM_ROTATION_ADDON_REFERENCE_2026-10-03.md`](ROM_ROTATION_ADDON_REFERENCE_2026-10-03.md)
- Native amount code: `src/game/romNativeCombatAmount.ts`, `src/game/romNativeHealing.ts`, `src/game/romNativeDamage.ts`
- Native rotation code and focused coverage: `src/game/romFiveRotation.ts`, `src/game/romFiveRotation.test.ts`, `src/game/romFiveRotation.integration.test.ts`
- Targeted fixed-cohort diagnostics: `src/game/romFiveNativeDungeonHealth.test.ts`, `src/game/romFiveNativeRotation.integration.test.ts`
No loot/progression, legacy/global enemy-HP curve, gear-cap, or readiness-promotion change is part of this pass. A separate native-stat RoM health budget was added and leaves the compatibility curve unchanged.
@@ -0,0 +1,43 @@
# Native RoM combat resolution and enemy damage types — 2026-10-03
This follow-up implements the requested native defense, hit/avoidance/critical, outgoing periodic-damage, special-input and multi-target consumers, and checks enemy damage classifications for both WoW and RoM. It supersedes the open native-combat boundaries in `ROM_NATIVE_COMBAT_PASS_2026-10-03.md`; the earlier report remains historical evidence.
The later [enemy equipment, encounter and pressure pass](ROM_NATIVE_ENEMY_ENCOUNTER_PASS_2026-10-03.md) supersedes this report's equipment/manual-enemy-formula limitations and final damage-budget/browser evidence. The measurements below describe this earlier resolution pass; the native health anchors remain current.
## Actor and source boundaries
Every player and party member retains their own stat ruleset. Supported canonical active-five native builds resolve against an exact imported RuneWaker NPC using `romNativeCombatResolution.ts`. The incoming path derives the recipient's own secondary class, gender and ruleset before choosing native resolution. WoW-mode recipients keep their compatibility resolver even when another party member uses native stats. Native points are never converted into WoW ratings.
`export-rom-native-combat-data.mjs` owns the compact source growth, FIX, skill, spell-binding and population-entity mapping data. `runewakerNpcId` is carried from population registration to combat state independently of WoW creature/server entry IDs. Player level remains capped at 80. The source provider accepts an explicit NPC level 81 in its tests; this does not raise the live scaling cap, and the final diagnostic's enemy levels stay at or below 80.
Canonical training gear and the five exact active class pairs are the supported player source scope. Missing source NPCs, custom/unverified gear, ambiguous source equipment or unsupported source calculations retain a declared shared fallback. NPC source derivation does not replace the game's authored/scaled encounter health pools or damage budgets. No loot, matchmaking or readiness promotion is included.
## Damage calculation, hit gate and school
These source fields have independent meanings. MagicCollect `EffectType` selects the hit gate. MagicObject `Attack.CalType` selects the direct physical or magical calculation and critical path. MagicBase `MagicType` identifies the native elemental resistance channel. A physical calculation can therefore deal Light damage; classifying every physical calculation as Physical school loses source information. DotMagic retains its own MagicBase `EffectType` semantics.
The compact enemy classifier keeps the WoW and RuneWaker spell-ID namespaces separate. WoW packets retain DBC `DmgClass` and the complete `SchoolMask`; the seven single schools map directly, while composite masks retain the existing authored single-school mitigation policy. Missing source spells and non-damaging script placeholders preserve authored packet types. RuneWaker stores the raw six channels separately from their simulator aliases: Earth/Wind → Nature, Water → Frost, Fire → Fire, Light → Holy and Darkness → Shadow. Exact native resistance lookup continues to distinguish Earth from Wind.
Source-qualified packets feed the existing structured combat events, immunity checks, absorbs and health application. Enemy ranged attacks no longer depend on a delivery-shape guess to select their hit kind. Classification coverage and source gaps are recorded in `ENEMY_DAMAGE_TYPES_AUDIT_2026-10-03.md`.
## Periodic damage and special inputs
`romNativePeriodicDamage.ts` owns source-negative HP/HP_Per DotMagic rank correction and application snapshots. Player casts and companion casts bind the exact skill/component. NPC casts also retain their assigned source MagicLv. Source target defense/resistance and caster power are evaluated at application; ticks reuse stored Buff.Power without new critical, hit, armor or resistance rolls. HP_Per reads the recipient's current maximum HP on every tick.
Enemy periodic effects have their own scheduler and are removed by dispels and encounter resets. Natural expiry preserves the final due tick; the source caster can die while an already-applied periodic effect remains. Native tick amounts then pass through ordinary generic dealt/taken modifiers and shields once. Unsupported snapshots retain the existing path. The exact source/runtime cases and unsupported reapplication/resource shapes are documented in `ROM_NATIVE_PERIODIC_DAMAGE_SOURCE_2026-10-03.md`.
The direct and periodic components of one source MagicCollect share the per-target application hit gate. Native hit/dodge/miss seeds therefore use a common cast-target key; critical/block/parry rolls retain their individual packet keys. Incoming periodic application also retains the actual attack revision. An exact native gate remains valid even when an unsupported direct calculation needs the shared amount/mitigation adapter: a passed gate disables the adapter's additional miss/dodge roll. The imported Big Ripper CalcType 4 regression covers this boundary. Cross-system WoW `incoming-*-hit` aura adjustments are not mapped into the native source gate; that mixed-aura bridge remains outside the active-five source scope.
The active-five source audit found Throw fixed inputs and raw ShieldDef inputs, with no prior-hit fixed types. Canonical training Throw derives the source Fin.Throw_DMG channel, including the second DMG_Per factor; shield calculations use raw BaseEQ shield defense, excluding fixed WearEq DEF. Prior-hit inputs remain explicit unsupported values until a real source consumer needs them. Multi-target source power receives actual recipient count and ordinal in both player and companion loops. Source sharing and ordinal falloff are applied once; the generic AoE falloff does not multiply the native result again.
## Validation
The source-resolution diagnostic before the health adjustment cleared all 80 fixed pulls with no deaths or invalid casts, with both selected NPCs source-supported. Trash took 4.5–17.7 seconds and Anglo 24–85.5 seconds. Those measurements justified changing only the native RoM health factors; the compatibility curve, damage budgets, difficulty multipliers and cap remain intact. `before-native-resolution-pacing.json` preserves this distinct baseline.
The final native health anchors are [trash, boss]: level 1 [0.4, 0.3], 10 [1.0, 0.6], 30 [1.1, 0.85], 60 [1.5, 1.5], 65 [1.8, 1.9], 66 [2.6, 2.8], and both 79/80 [4.5, 4.8]. Intermediate levels interpolate linearly. These are measured simulator training-party budgets, not RuneWaker monster-health formulas. The static cohort is diagnostic evidence for these five requested builds, not an all-class balance certification.
The final report clears all 80 pulls at levels 1/10/30/60/65/66/79/80, with zero invalid casts, player deaths or companion deaths. Both the actual trash NPC and Anglo are native-supported in every sample. Trash is 4.9–8.7 seconds and Anglo 40.5–58.5 seconds. The harness asserts source support, successful kills and absence of invalid casts/deaths; positions remain fixed, so this does not certify encounter movement or every dungeon.
Focused validation passed 140 tests across 18 files, followed by six shared-gate/periodic checks after the final result-field cleanup. Native HP/pacing checks passed 7/7. Ready-five checks passed 7 Vitest and 20 Python cases; avatar/equipment checks passed 47 cases during this follow-up. The final production build stages passed: bootstrap and asset audits, TypeScript, Vite, compression and release-bundle verification.
The final production browser checks use `index-DyCSc4Cz.js` and `combatStore-nQykOmDD.js`. Stat-ruleset QA covers native defaults, legacy WoW-mode saves, independent player/companion modes, switching guards, equipment panels and a 390×844 viewport. The targeted Boss Testing check covers Anglo's Gulp control effect and Lady Anacondra's Nature-school Lightning Bolt against a native RoM party, with no page errors. It does not cover every boss mechanic or dungeon. Evidence is in `playtest-artifacts/stat-rulesets/results.json` and `playtest-artifacts/native-combat/results.json`; screenshots verify roster/dungeon bodies, staff/shield/weapon silhouettes, native stats and encounter feedback.
@@ -0,0 +1,41 @@
# RoM native combat resolution and source boundary — 2026-10-03
This note documents the narrow native combat resolver in `src/game/romNativeCombatResolution.ts`. It supplies Runewaker hit, avoidance, critical, typed resistance, and ATK/DEF or MATK/MDEF stages to the shared `CombatResult` shape. The resolver accepts only `ruleset: "rom"`; WoW and CoA requests return an explicit unsupported result and never reinterpret WoW ratings as Runewaker points.
## Source inputs and identity
`scripts/runewaker-pipeline/export-rom-native-combat-data.mjs` reads local `npcobject.db`, `magiccollectobject.db`, `magicobject.db`, vocation/race/sex growth, FixTable, and SkillValueBase/JobSkillValue records. It exports a compact `entityIdToNpcId` map from generated population entity IDs to raw Runewaker NPC IDs. `romNativeNpcIdForEntity` only resolves that map. The raw ID is not an AzerothCore `serverEntry` and must not be passed to server-entry registration.
Player hit profiles are bound to exact imported `(sourceSkillId, componentId)` pairs. NPC hit profiles are bound to the NPC's exact source spell row and component. The profile keeps `MagicCollect.EffectType`, `HitRateFunc`, and its two raw arguments separate from `MagicObject.Attack.CalType`, `AttackType`, and `MagicType`: the collect effect selects the source hit gate, calculation type selects physical/magical damage and critical handling, and MagicType selects one of the six elemental resistance channels (Earth, Water, Fire, Wind, Light, Darkness). An elemental school is therefore not inferred from the physical/magical calculation channel.
NPC derived attributes use authored NPC attributes plus the source vocation, race, and sex growth tables at the explicit runtime level. NPC runtime levels accept the source's 1–200 range; the level-80 player cap is not applied to scaled NPCs. Combat snapshots additionally require an available skill-table row (currently through level 100). No source hit/avoidance table is silently extrapolated past its exported rows.
`romNativeNpcEquipment.generated.json` preserves all five main/off-hand candidate IDs (empty entries and duplicates at 20% each), fixed BowEQ/OrnamentEQ IDs, item WearEQ values, refinement and explicitly separate drop metadata. `romNativeNpcEquipment.ts` resolves deterministic source candidate selection, durability/power-quality wear power and verified refine sequences. Supported channels feed native attributes, attack/defense, healing, resistance and main-hand speed/range/damage. Live callers use the NPC-id seed; actor-specific seeds and explicit candidate overrides are available. DropItemAbility metadata is not worn affix data and is not applied. The default selection supports 398/440 imported NPCs, including Barren 101459–101461 and Anglo. Remaining off-hand weapon damage, bow/ornament channels, unknown effects and vocation mismatches fail closed. See [the equipment pass](ROM_NATIVE_NPC_EQUIPMENT_PASS_2026-10-03.md). NPC periodic target/caster data may be present for inspection on an unsupported profile, but callers must gate native combat use on `combatStatus === "supported"` / `nativeCombatant`.
Exact enemy manual fixed/MaxHP packets now use `romNativeEnemyDamage.ts`; their source stages differ from ordinary physical/magical attack formulas and skip those generic power stages. Source absorption/AoE inputs require verified neutral canonical training gear and aura state. Exact source amounts retain authored encounter multipliers and bypass the native shared-damage calibration. Unbound Throw and unknown fixed inputs remain explicit gaps. [The encounter follow-up](ROM_NATIVE_ENEMY_ENCOUNTER_PASS_2026-10-03.md) records the updated runtime boundaries and pressure evidence.
## Combat stages
The positive damage amount arrives with fixed-value and supported ability-power inputs already resolved by the native amount helper. Unless `sourcePowerStagesAlreadyApplied` is explicitly set, the resolver applies the source level-difference multiplier, the attacker's exact typed native magic-power rate, and the defender's exact typed resistance to magical channels. The source resistance routine works on signed negative damage: it adds raw integer resistance when the damage magnitude is large enough, halves damage when resistance exceeds half of the damage, then multiplies by the typed resistance rate. Missing school inputs return unsupported rather than using a guessed school or WoW Nature mapping.
For NPC attackers, source `Global::DLvRate` uses the verified level-difference table, whose result bottoms at 1 and caps at 2. Player attackers use 1. Native mitigation then uses `max(0.2, 1 + ((ATK or MATK)+100 - ((DEF or MDEF)+200)) / max((ATK or MATK)+100, (DEF or MDEF)+200))`. Auto attacks also retain the source 0.3 minimum. The critical or parry multiplier is applied before this defense ratio, and the final amount is truncated once, at the end of those multipliers.
Auto-attack physical avoidance follows the source dodge-then-miss order. Physical miss compares native weapon and Define skill points; a player weapon skill receives the source `+10`, while an NPC uses `level * 2.3` directly. Define skill receives `+10`. Physical hit/dodge uses the source FIX hit base/range and raw point ratio, source body hit/dodge channels, and the non-player-versus-player level adjustment. Spell hit checks use the exact imported MagicCollect function: base, linear level, signed-square level, per-target ordinal, or Shoot. For a physical-effect spell with a non-base function, source physical skill miss is rolled first; integer physical hit rate then modifies the collect hit check, whose failure is reported as dodge. Spells do not use a separate auto-attack dodge roll. Unavailable Shoot skill or invalid source arguments fail closed.
The source physical avoidance table checks block, then parry, then critical. Block and parry rates come from native point-vs-resist point formulas with FIX base and level terms; they are not percentages. Parry also consumes the source ParryRate body-rate adjustment, halves the landed damage, and prevents a critical. A block prevents damage. Imported HP attack types gate block/parry, and callers can disable individual outcomes through `CombatResultRules` for source components or mechanics that disallow them.
Critical profiles use the actual physical or magical `CalType`, not the collect effect or elemental school. Critical points are compared against the target's native `ResistCriticalHit` points and source FIX base/level terms. Direct magic critical uses the source integer threshold and ±10 level adjustment; auto physical critical uses the source floating threshold and ±20 adjustment. The resulting `CombatResult` retains the shared outcome/table fields while source metadata records the selected channel, applied source rates, and defense ratio.
At DoT tick time, `targetMitigationSnapshotted` (also accepted from `CombatResultRules`) bypasses hit, critical, resistance, and DEF/MDEF because application-time native DoT snapshots already contain those stages. Immunity is checked before that bypass. This keeps snapshot damage from being mitigated twice.
## Snapshot factories and explicit simulator policies
`romNativeNpcCombatant(npcId, runtimeLevel?)` is the NPC snapshot factory; `romNativeNpcIdForEntity(entityId)` supplies its raw source ID for imported populations. NPC snapshots are cached by source ID and exact runtime level.
`romNativeTrainingCombatant(stats, trainingGearProfileMatches, weaponSlot)` creates a player/party snapshot only after the caller confirms the complete canonical training gear profile. It reads the compact generated training profile and verified neutral training-item body defaults. Weapon/Define skills use the source SkillValueBase/JobSkillValue maximum at the current player level; this is an explicit simulator auto-training policy, not a claim about an individual character's training history. Custom or unverified gear returns unsupported. Typed magic rates and resistances retain six source channels.
`nativeMagicInputsForType(snapshot, magicType)` exposes the exact typed power rate and resistance for periodic snapshot callers. `romNativePlayerHitProfile` and `romNativeNpcHitProfile` expose only the exact exported binding pairs. `resolveRomNativeCombatResult` returns a full shared `CombatResult` or an explicit unsupported reason; a caller must preserve its damage school and ruleset rather than relabeling a fallback result as native.
## Verification
`src/game/romNativeCombatResolution.test.ts` covers native ATK/DEF and MATK/MDEF ratios, hit ordering, the NPC weapon-skill exception, block/parry, bound hit/calculation channel separation, typed MPow and resistance, NPC level 81, the supported Anglo neutral off-hand, fail-closed non-neutral equipment, neutral verified training supplements, immunity, and snapshotted mitigation. Source-level claims were checked against the local read-only `Runewaker/SourceCodeVS2019/Libraries/Union/RoleData/RoleAttr.cpp`, `RD_ValueCal.cxx`, `RD_ValueCal.inl`, `RD_Normal.cxx`, `RD_Setting.cxx`, `ObjectStruct.h`, and the referenced binary DB layouts. This is a source-derived subset; unresolved equipment, beyond-table skills, unsupported calculation types, missing supplements, and cross-ruleset inputs remain explicit unsupported cases rather than certified native combat.
@@ -0,0 +1,34 @@
# Native RuneWaker enemy direct damage — 2026-10-03
## Scope and runtime contract
`src/game/romNativeEnemyDamage.ts` resolves a direct enemy packet only from an exact imported `(NPC ID, source skill ID, component ID)` binding in `romNativeCombatData.generated.json`. `scripts/runewaker-pipeline/export-rom-native-combat-data.mjs` owns that generated binding data. The helper keeps the three source meanings independent: `MagicCollect.EffectType` selects the hit gate, `MagicObject.Attack.CalType` selects this formula, `MagicObject.EffectType` selects the CalType 8 absorber, and `MagicObject.MagicType` remains the native resistance channel. A formula packet reports `sourceHitEffectType`, `componentEffectType`, and `magicType` separately.
The helper implements negative direct-HP CalType 4 (fixed points) and CalType 8 (recipient MaxHP percentage) rows when all source inputs are present. It returns `unsupported` for positive/healing rows, unsupported component shapes, missing exact bindings, missing MaxHP, missing fixed-attribute values, unverified target absorption, or missing non-target AoE rates. It never guesses an NPC from a skill name. The source catalog has 440 NPC rows and currently contains 22 direct CalType 4 components, 11 direct CalType 8 components, and zero direct CalType 2 NPC-bound components. Day of Rain's CalType 2 skill 491183/component 501278 is catalogued, but this snapshot does not attach it to an imported NPC; the helper rejects that unbound pair.
For CalType 4, source power is `DmgPower * (1 + DmgPower_SkillLVArg * MagicLv / 100)`, plus `FixValue * GetValue(FixType) * (1 + MagicLv * FixDmg_SkillLVArg / 100)`. A nonzero fixed term with a nonzero FixType needs an explicit source attribute value. The known None FixType contributes 1. Negative power goes through target `CalMagicDMG` unless `IgnoreMagicAbsorb` is set. A non-target cast then applies source `AEMagicPowerRate * AEMagicDefRate`. The source share factor is multiplied into the absorbed/AoE-adjusted power before integer damage; unshared recipient ordinal attenuation modifies the base-power term before fixed power and target absorption.
For CalType 8, the target's current native MaxHP multiplies the source percentage before absorption. With absorption enabled, CalWeaponDMG or CalMagicDMG is evaluated against that HP-scaled value first and divided by 100 afterward, matching the source expression's order and truncation. Raw MagicObject `EffectType` 1 selects weapon absorption; 0 selects magic absorption. `IgnoreMagicAbsorb` and `FixDotPower` bypass that absorption. Non-target magical rows apply the AoE magic power/defense rates in the ordinary CalMagicDMG branch; `FixDotPower` takes the source's early raw-MaxHP branch and skips those rates. The share factor is applied after integer damage; unshared recipient ordinal attenuation modifies the source percentage before target MaxHP and absorption.
The supported direct point formulas set `resolverDamageChannel: "none"` and mark applicable source power stages as resolved or not applicable. The shared native resolver therefore retains only the independent collect hit gate and does not reapply level damage rate, typed power/resistance, generic DEF/MDEF, criticals, block, or parry. Ordinary damage auras and shields remain later simulator stages. Target source `AB/Abr/MAbr` is a separate formula input: `romNativeTrainingEnemyAbsorbInputs` supplies the verified canonical training profile's neutral values only when gear matches and the caller explicitly confirms no native absorb-channel aura is active. Custom gear or unverified auras fail closed.
The helper explicitly skips `nativeDamageBudgetScale` for exact source formulas because their source points or target-HP percentage already determine the native magnitude. Imported `mob.damageMultiplier` still applies after the formula. Authored encounter health and the simulator's health curve remain independent of native damage calculation.
CalType 2 Throw remains explicitly unsupported by the runtime helper. Its source path uses `Fin.Throw_DMG`, then `CalPhyDamage` applies NPC damage rate, `Global::DLvRate`, and typed resistance before the source combat table; target `CalWeaponDMG` and share damage happen after critical/block/parry. The current runtime has no completion hook at that exact post-table stage, so the helper returns `native-enemy-throw-post-table-absorption-runtime-unwired` instead of a partial amount. This source catalog has zero direct CalType 2 NPC-bound components; Day of Rain (491183/501278) has no exact imported NPC association and is never inferred.
## Source evidence
- `SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp`, `Magic00_AddHP` around lines 2286–2980: direct `AtkCalType` dispatch, fixed/percentage formulas, recipient iteration, share/ordinal behavior, and final integer damage.
- `MagicProcess.cpp` around lines 2532–2610: CalType 4 `OrgPower`, `AddFixPower`, target CalMagicDMG, AoE rates, and share factor.
- `MagicProcess.cpp` around lines 2751–2840: CalType 8 recipient MaxHP calculation, raw component EffectType selection, FixDot/IgnoreMagicAbsorb bypass, AoE rates, and final integer damage.
- `MagicProcess.cpp` around lines 2477–2515: CalType 2 uses `Fin.Throw_DMG`; `CalPhyDamage` around lines 3366–3398 adds its fixed term, applies NPC damage rate, `Global::DLvRate`, and typed resistance. Its target weapon absorption occurs after block/parry/critical handling around lines 3478–3560.
- `SourceCodeVS2019/Libraries/Union/RoleData/RoleAttr.cpp` around lines 178–209: `Init_BaseEQValue` zeroes `Body` and initializes all `BodyRate` entries to 1. `CalWeaponDMG` and `CalMagicDMG` around lines 941–958 define the flat/rate absorption and minimum one-point damage behavior.
- `SourceCodeVS2019/Server/NetGameServer/MainProc/Global.cpp` around lines 8928–8964 defines the recursive source level-difference rate table, including its NPC lower-bound behavior.
- `src/game/generated/romNativeDamageTrainingProfiles.generated.json` is owned by the native training profile exporter. Its 999 selected training items and affixes have resolved source channels; no selected profile supplies AB/Abr/MAbr/AEMagicDefRate. NPC accepted WearEQ types are separately fail-closed when an unhandled nonzero ability appears, so the supported NPC subset has neutral `AEMagicPowerRate` from source BodyRate initialization plus its resolved equipment projection.
- `src/game/generated/enemyDamageTypeSources.generated.json` is owned by `scripts/dungeon-pipeline/export-enemy-damage-types.mjs`; the compact classifier now exports formula names and absorber policy independently of packet class, raw effect type, and MagicType.
## Verification and limits
`src/game/romNativeEnemyDamage.test.ts` covers exact Barren Axe Throw CalType 4 (NPC 101255, skill 492857/component 503019), Snow's Dance CalType 8 (NPC 101535, skill 492438/component 501098), target absorption, target order, AoE source rates, missing inputs, unbound Day of Rain, and canonical/custom training gear gates. Hall of Survivors NPC 102476/skill 493985/component 503044 is the only current non-target, magical-absorb CalType 4 direct NPC row, but its NPC equipment snapshot is unsupported; its AoE arithmetic is unit-tested with explicit source/target BodyRates, while live runtime correctly stays on the fallback. `src/game/mobDamageTypes.test.ts` verifies exported formula metadata and keeps hit-gate, absorber and resistance fields distinct. `src/game/romNativeEnemyDamage.integration.test.ts` covers the actual Treasure Trove packets against canonical active-five player/companion targets at levels 30 and 80.
CalType 2 target absorption remains pending after the shared combat table and is not integrated for current imported encounters because there are no exact NPC bindings. Non-target magical source packets require a verified source AoE-power multiplier; active source auras that may change that channel must remain fail-closed until represented. CalType 4 fixed types outside None require a caller-provided source attribute. The fixed and MaxHP branches intentionally do not claim support for buffs, healing, periodic damage, MP damage, extra PvP-only behavior, or source shapes beyond their direct HP branch.
@@ -0,0 +1,67 @@
# Native RoM enemy equipment, encounter packets and healing pressure — 2026-10-03
This pass completes the requested follow-up to native combat resolution: source NPC equipment, supported fixed/percentage enemy damage, targeted encounter reactions, and measured healing pressure. The level cap remains 80. Existing saved RoM characters retain WoW stats until switched; each player/companion selects its own resolver. No loot, matchmaking, difficulty-slider change or readiness promotion is included.
This report preserves the earlier pass and its shared-regeneration measurements. The subsequent [recovery, equipment and pacing follow-up](ROM_NATIVE_RECOVERY_EQUIPMENT_PACING_PASS_2026-10-03.md) supersedes the 398/440 equipment count and native HP/Mana recovery limitation below.
## Equipment and source damage
`export-rom-native-npc-equipment.mjs` owns the compact NPC candidate/item/refine snapshot. `romNativeNpcEquipment.ts` resolves actual source candidate slots, wear power and known WearEQ channels rather than averaging candidate stats. Main-hand speed/range/damage and supported attributes, attack, defense, healing and resistance feed `romNativeNpcCombatant`. Current live callers use the stable NPC-id default seed; the provider also accepts an actor seed and explicit candidate overrides. Separate copies therefore currently select the same equipment for the same source NPC. Equipment drops/affixes are not attached to loot.
Under that default selection, 398 of 440 imported NPCs have supported native snapshots. The remaining 42 retain explicit fallback: 26 selected off-hand weapon-DMG cases, ten bow/ornament cases, two unknown item effect cases, and four preexisting vocation-sub-ID mismatches. See [the equipment source report](ROM_NATIVE_NPC_EQUIPMENT_PASS_2026-10-03.md) for exact source paths, item coverage and hashes.
`romNativeEnemyDamage.ts` binds exact NPC/skill/component and source MagicLv for manual fixed damage (CalType 4) and recipient-MaxHP percentage damage (CalType 8). These branches preserve their source absorption, integer, sharing and ordinal order; they do not acquire generic ATK/DEF, elemental resistance or critical multipliers. Native target absorption is accepted only for verified canonical training gear and represented neutral aura channels. Non-target magical packets additionally require a supported NPC body/equipment snapshot and verified neutral source AoE power; an NPC with active unverified auras falls back. Generic dealt/taken modifiers and ordinary shields remain in the existing application path.
Source Throw (CalType 2) is audited separately: no exact imported NPC binding currently uses that calculation. An unbound source spell is not attached to an enemy merely to exercise the branch. Missing fixed attributes, unknown equipment or source effects remain unsupported rather than borrowing WoW inputs. See [the enemy formula source report](ROM_NATIVE_ENEMY_DAMAGE_SOURCE_2026-10-03.md).
The live direct-damage consumer uses the source formula amount with the existing authored `mob.damageMultiplier`. It bypasses the new native training-party damage budget for exact CalType 2/4/8 amounts. This prevents multiplying an already sourced fixed or maximum-HP packet by the high-level shared-damage calibration. Source skill rank stays on the NPC binding; this pass does not claim every fixed-damage encounter is balanced when scaled to every player level. Encounter health continues to use authored/scaled pools.
## Encounter behavior
The headless encounter checks use actual Anglo Trample/Bash/Gulp definitions with only initial cooldown accelerated for isolated mechanic tests. Trample and Bash retain their source 1.8-second warning and five-unit range. Moving the player and Mage outside the radius during the warning excludes them while the remaining nearby companions receive the physical packets. Gulp applies to actual nearby recipients; manual player/NPC dispels and a real native NPC Priest cleanse remove control before natural expiry. A real companion interrupt cancels Trample without damage.
An interrupted/stunned/silenced enemy cast now emits its cast-cancel presentation event when clearing the active cast. Previously the simulation cancelled the cast while its telegraph/presentation could remain active. Self-target Regenerate ticks now enter the structured healing ledger with raw/effective/overheal fields; healing itself is unchanged. Both fixes have direct regression coverage.
The pressure harness uses the actual mob/party tick pipeline with scripted stacked/spread positions. It is not a scene-navigation certification. Its 16 pulls cover levels 10/30/60/80 with an active or idle player Priest and the four other exact native builds. A separate 40-case damage integration selection covers fixed and MaxHP packets against all five builds in both player/NPC seats at levels 30/80. These are explicitly requested diagnostics for Needs Testing builds, outside randomized/all-class certification.
## Measured calibration
`before-pressure-tuning.json` preserves the post-equipment, pre-pressure-adjustment baseline. Before tuning, level-80 Anglo dealt only 335–402 effective enemy damage over roughly 40–57 seconds against a 49,210-HP tank. Large spread-party healing totals were chiefly native Vampire Arrows health costs, not boss damage. The report now separates enemy damage from self/other ability damage so those costs cannot masquerade as encounter pressure.
`dungeonBossScaling.ts` exposes `nativeDamageBudgetScale` separately from authored/difficulty damage multipliers. It applies only to a RoM-origin native-stat party's shared enemy-damage budget. Anchors are level 1:1, 10:1, 30:3, 60:10, 65:12, 66:20, 79:40 and 80:40; intermediate values interpolate. `MobPopulation.tsx` passes it into live registration. `combatStore.ts` scales shared attack damage and its attack-power contribution together and handles repeated registration without compounding. The compatibility curve, native health anchors and difficulty controls stay intact.
| Level-80 pressure sample | Enemy damage | Player effective healing | Lowest party HP | Clear time |
| --- | ---: | ---: | ---: | ---: |
| Active Priest, spread | 13,415 | 54,258 | 82.0% | 49.1 s |
| Active Priest, stacked | 13,415 | 13,415 | 82.0% | 41.1 s |
| Idle Priest, spread | 16,098 | 0 | 13.0% | 57.1 s |
| Idle Priest, stacked | 13,415 | 0 | 48.7% | 52.0 s |
The spread sample still includes substantial Scout health costs, recorded separately in `otherDamage`. All 16 pulls cleared without deaths or invalid controller casts. Active healing remains comfortable; idle stacked health falls materially below the baseline. This is a first measured normal-mode training budget, not a difficult-mode or every-dungeon guarantee. Mana minima remain approximately 95–99% for the active samples under the shared regeneration policy. Native MP regeneration, resource sustain and mana-pressure balance remain open; this pass does not imply mana challenge parity.
The regenerated fixed full-player/four-NPC cohort clears all 80 requested pulls at levels 1/10/30/60/65/66/79/80, with no player/NPC deaths or invalid casts. Trash takes 4.9–8.7 seconds and Anglo 42.0–58.5 seconds. Both exact enemy snapshots are source-supported. Static positions omit navigation; the separate mechanic fixtures cover selected movement and reactions.
## Evidence and validation
- `artifacts/rom-enemy-followup/before-enemy-source-expansion.json`: preserved previous native-resolution cohort.
- `artifacts/rom-enemy-followup/before-pressure-tuning.json`: complete 16-pull pressure baseline.
- `artifacts/rom-enemy-followup/pressure.json`: final pressure ledger and ability-level self/other damage.
- `artifacts/rom-five-native-rotation/report.json`: final 80-pull cohort.
- `src/game/romNativeEncounterPressure.integration.test.ts`: six encounter/pressure checks.
- `src/game/romNativeEnemyDamage.integration.test.ts`: player/NPC formula and fallback coverage.
Focused native/source validation passes 141 distinct tests across 22 files, including the 40 exact packet cases and six encounter/pressure checks. The repeat-registration/mode-switch regression also verifies that pressure does not compound or survive a switch to compatibility scaling. The separate 80-pull cohort passes its diagnostic test. Avatar checks pass 47/47; Ready-five checks pass seven Vitest and twenty Python cases. The app TypeScript check passes with the repository's required larger Node heap.
The final `npm run build` passes bootstrap generation, texture/performance audits, TypeScript, Vite, static compression and release-bundle verification. The tested production chunks are `index-pDR2PGJY.js`, `combatStore-7m63VWbc.js` and `GameRuntime-CGZMQPoV.js`. The asset audit has no new regressions; existing chunk-size/static-dynamic-import warnings remain.
Exact production Edge QA passes with no page errors. The UI-created level-30 native Knight/Scout and four native companions enter real Boss Testing scenes for Anglo and Lady Anacondra. Both normal snapshots record native damage budget 3; Anglo attack damage is 289 with authored multiplier 1.3, and Lady's is 354. These are live population-registration checks, not injected mob-stat assertions. Roster and in-dungeon screenshots visibly retain player/party bodies and starter shield/sword/staff silhouettes.
The final `--fixtures-only` run reuses those successful normal-scene snapshots from the same production chunks (`normalScenesReran: false`) and stages actual Anglo Trample, Bash and Gulp individually. Only cooldown/readiness, attack-list selection, threat and positioning are fixture setup; Trample companion actions are paused to keep its warning observable. Each actual source attack keeps its 1.8-second cast, range, effects and telegraph and releases once. Moving out of Trample's hit area produces zero player damage events; Bash retains its five-unit range. Gulp controls a nearby Rogue and clears at/after source expiry; this browser sample has no Priest cleanse evidence. The separate headless test proves NPC cleansing with the Priest outside Gulp's radius. Test setup was corrected to reset position/source attack lists between fixtures; no runtime change was needed.
Evidence is in `playtest-artifacts/native-combat/results.json`, the two normal sample JSON files, roster/normal scene images and six `anglo-*-fixture.png` images. Representative roster, party/dungeon, Trample warning/movement, Bash and Gulp screenshots were visually inspected. This selected production QA does not certify a normal full clear, full navigation, mobile UI or every encounter. Harness syntax checks and repository/vault text hygiene pass; no readiness reviews or dungeon reports were submitted.
The additional `dungeonBossScaling.test.ts` run passes 13 checks and fails four compatibility-pacing expectations: WoW Stockades at levels 1/3 (14.6/15.1 seconds versus a 24.5-second floor), legacy RoM Mage at level 8 (23.6 versus 25 seconds), and legacy RoM at level 80 (11.3 versus 25 seconds). A Luna subprocess copied the committed pre-native scaler from `b16821e` into ignored scratch files and reproduced the two WoW starter failures and the RoM Mage level-eight failure with the current surrounding runtime. It did not replay the exact level-80 healer case; an additional level-80 Mage diagnostic is a different cohort. Committed/current legacy scaling outputs match for all four original inputs, and these omitted-ruleset calls use native damage budget 1. This isolates the new native scaling branch from the health/damage outputs; it does not certify the entire committed runtime or establish an exact four-case committed replay. Scratch files were removed. Compatibility pacing was not retuned and the failed expectations remain visible.
## Remaining scope
Off-hand weapon damage, bow/ornament equipment, unknown item effects, unbound Throw, unknown fixed attributes, unrepresented absorb/AoE aura channels and custom gear continue to fail closed. Cross-system incoming-hit aura translation into native source gates, native resource regeneration, full scene navigation and broader encounter balance remain outside this pass. RoM classes remain Needs Testing.
@@ -0,0 +1,111 @@
# RoM native Priest healing source adapter
This is a local RuneWaker source implementation for native RoM healing. It is
not a Chronicles of Arcadia formula, and it does not infer a spell rank from a
source catalog value. `MagicProc.MagicLv` is a required runtime input. The
simulator may explicitly map its trained skill level to
`clamp(characterLevel, 1, 80)`; that is simulator policy, not a claim about the
source database's configured `MagicLV` field.
## Direct HolyHeal
`romNativeHealing.ts` resolves the `MagicProcess.cpp` `AtkCalType::HolyHeal`
branch. The source equation is:
```text
OrgPower = DmgPower + DmgPower * DmgPower_SkillLVArg * MagicLv / 100
SpellTime = MagicCol.SpellTime; use 1 when below 1 or IsFixDmgEffect is true
MNDPart = MND * 0.3 * SpellTime
Power = OrgPower * 6 when OrgPower * 5 < MNDPart; otherwise OrgPower + MNDPart
unless IsIgnoreDmgEffect:
add MDMG * FIX.Dmg_HealArg, once when fixed or multiplied by SpellTime otherwise
Power = Power * MPow * HealPowerRate + HealPoint * SpellTime
per target, apply Atk.Rand variation
unless MagicBase.SettingEx.IgnoreMagicAbsorb:
Power = Power * (1 + HealAbsorbRate / 100) + HealAbsorb
clamp Power below zero; reverse sign for undead; suppress negative undead output out of combat
```
The generated source map binds ability and rank identity to raw `MagicCollect`
rows and direct HP `MagicObject` components. It preserves source component IDs,
`AtkCalType`, signed `DmgPower`, its skill-level argument, fixed fields,
random range, critical rate, `SpellTime`, range/sharing data, and the distinct
`MagicCollect` timing flags and mode flags. `holyHealBindings` is the 18-row
subset with `AtkCalType == 5`; `attackBindings` retains all 225 direct HP
ability rows for shared combat integrations.
The Priest bindings include Heal (`490261`, `DmgPower 90`, argument `30`,
`SpellTime 2`), Group Heal (`490281`, `DmgPower 65`, argument `20`,
`SpellTime 3`), and Urgent Heal (`491147`, `DmgPower 35`, argument `20`,
`SpellTime 1`). Their exact component and rank records are in the generated
metadata. The helper expects already-effective native caster and recipient
channels; callers must not run generic healing modifiers a second time when
those modifiers were included in the effective inputs.
## Periodic HP healing
Runewaker has a separate native HoT path. It is not a HolyHeal cast repeated
each tick. `MagicProcess.cpp` computes the applied buff duration and invokes
`CalAddDotMagicLv`; `RD_Normal.cxx` calculates the buff's source rank
correction; `RD_ValueCal.cxx::BuffProc` uses the frozen `Buff.Power` at each
tick. The helper supports active positive magical `DotMagic` with HP type 0,
including Priest's Regenerate (`490269`). It deliberately returns no result
for unsupported `DotMagic` types, including percentage HP type 5.
At application, the source first derives actual buff duration from
`EffectTime`, `EffectTime_SkillLVArg`, `DLV_EffectTime`, and owner/target
levels, then uses `DotTimes = max(1, int(EffectTime / Dot.Time))`. For a
positive magic HP dot, the source calculates:
```text
DotBase = Dot.Base * (1 + MagicLv * DotSkillLVArg / 100)
ExRate = 0.6 + abs(DotBase) / 600 for an IsPlayer owner; otherwise 1
Power = DotBase + ExRate * MDMG * int(SpellTime) * FIX.Dmg_HealDotArg / DotTimes
MNDPart = MND * 0.3 * int(SpellTime) / DotTimes
Power = Power * 6 when Power * 5 < MNDPart; otherwise Power + MNDPart
Power = Power * HealPowerRate + HealPoint * int(SpellTime) / DotTimes
unless IgnoreMagicAbsorb:
Power = Power * (1 + HealAbsorbRate / 100) + HealAbsorb / DotTimes
clamp Power below zero; Power *= MagicPow(MagicType)
MagicLvAdjustment = int((Power - Dot.Base) / (Dot.Base * DotSkillLVArg / 100) - MagicLv)
Buff.Power = MagicLvAdjustment + MagicLv
```
`FixDotPower` or a zero `DotSkillLVArg` bypasses the rank correction, leaving
`Buff.Power = MagicLv`. The source tick then computes
`Damage = int(-Dot.Base * (100 + DotSkillLVArg * Buff.Power) / 100)` and applies
`AddHP(-Damage)`. C++ integer conversion truncates toward zero. Recipient
absorb and caster channels are captured through the buff-rank correction at
application; the source does not reapply them on each tick. The helper returns
the resulting integer tick amount and the `DotTimes` interval count so the
runtime can preserve that application snapshot.
The live helper requires an explicit effective
`MagicPow(MagicType)` value. The existing native stat sidecar has an all-magic
neutral MPow channel but does not expose all six element-specific MPow
`BodyRate` channels. The caller must supply an effective source value when
element-specific modifiers are modeled. For this simulator's active
player-build identities, `sourceIsPlayer=true` is an explicit policy applied
to both the active player and party companions; the original `IsPlayer()` test
itself distinguishes actual server player roles.
The current Priest Regenerate source row contains positive HP DotMagic
components `500469` (argument 18), `621318` (argument 18), and `621548`
(argument 25). They share `Dot.Time 2`, `Dot.Base 20`, and duration 20 seconds.
The generated map retains each component ID so the runtime can use the
component selected by its own source semantics instead of guessing which raw
component is active.
## Source snapshot and limits
The generated artifact is
`src/game/generated/romNativeHolyHealBindings.generated.json`; regenerate it
with `scripts/runewaker-pipeline/export-rom-native-healing.mjs`. It is tied to
the current hashes of `magiccollectobject.db`, `magicobject.db`, the local
server formula files, the player catalog, and generated native stat rules.
The output includes 225 direct-attack ability rows, 18 HolyHeal ability rows,
and 8 positive magical HP DotMagic ability rows across the catalog.
The data and formula boundary is the local RuneWaker source version hashed in
that artifact. This work does not add CoA rank data, model percentage HP
periodics, or infer unavailable source skill levels from `configuredCollectMagicLevel`.
@@ -0,0 +1,33 @@
# Native RoM level-one boss pacing — 2026-10-03
## Adjustment
The native RoM health policy now uses a `0.20` boss-health scale at character level 1, down from `0.30`. The level-1 trash scale remains `0.40`. Every health anchor at level 10 and above is unchanged, as are all `nativeDamageBudgetScale` anchors. The level-1 boss value interpolates linearly to the existing level-10 boss value, so native bosses at levels 2–9 also receive the corresponding smooth interpolation. This is a simulator health budget for the targeted five-build cohort, not a RuneWaker source monster-health formula.
The adjustment followed source recovery and the separately implemented level-one Priest mana reserve. With the previous `0.30` boss value, the level-one native Knight diagnostic still ended with actor deaths; the reported pull had 391.2 incoming damage and 56 healing. The authorized retune changed only the level-one boss-health anchor.
## Five-build result
The unchanged 80-pull native rotation diagnostic passed at the `0.20` anchor. It covers each of the five identities as the active character and four native companions against Barren Caves trash and Anglo at levels 1, 10, 30, 60, 65, 66, 79, and 80. All 80 targets died; every player survived, no companion died, every native combatant was supported, and there were no invalid cast failures. This is a fixed-position simulator diagnostic, not scene navigation or a readiness promotion.
The pair names below identify each configured build; at level 1, secondary classes are inactive, so these five samples all use pre-secondary snapshots. They all used the same 1,848-HP budget and had no player or companion deaths:
| Active build | Clear after first party hit | Player HP remaining |
| --- | ---: | ---: |
| Knight/Scout | 42.1s | 95.6 |
| Priest/Knight | 34.1s | 152.4 |
| Rogue/Warrior | 36.9s | 129.6 |
| Scout/Rogue | 39.7s | 120.4 |
| Mage/Warlock | 34.1s | 142 |
The complete generated diagnostic is `artifacts/rom-five-native-rotation/report.json`. `romNativeDungeonPacing.test.ts` now locks the level-one Anglo value at 1,848, verifies that level-one native trash remains at the unchanged 0.40 ratio, and checks the unchanged damage budget. The level-80 native health plateau/cap assertion remains exact and unchanged.
## Interpolation and boundaries
The new level-1 boss anchor changes the interpolated native level-8 Stockades regression from 8,330.293333 to 7,983.197778. Its expectation was updated to the computed interpolation; the native level-80 Stockades health remains 1,312,656 with damage-budget scale 40. The legacy WoW-stat RoM pacing curve and its mixed-mode harness are unchanged.
No trash-health anchor, level-10-or-higher health anchor, native damage-budget anchor, authored damage multiplier, source enemy damage, equipment, loot, level cap, actor mode, or Ready-party policy changed. This calibration does not claim a complete early-level balance pass or readiness status.
## Validation
Focused verification passed `romNativeDungeonPacing.test.ts` and the unchanged `romFiveNativeRotation.integration.test.ts`: 6 tests total, including 80 pulls. The level-one five-build results and full diagnostic metadata are archived in `artifacts/rom-five-native-rotation/report.json`. The post-recovery legacy boss pacing suite passed 18/18 tests; its 83-sample report is archived at `artifacts/rom-enemy-followup/after-compatibility-pacing-post-recovery.json`.
@@ -0,0 +1,35 @@
# Native RoM NPC Equipment Coverage Completion — 2026-10-03
## Result
The native NPC equipment resolver now supports all 440 generated source NPCs under the default deterministic selection and every explicit main-hand/off-hand candidate pair: 440 defaults plus 11,000 candidate-pair snapshots (25 per NPC). The five source candidates and their 20% weights are preserved; the resolver still chooses one reproducible candidate rather than averaging equipment.
The pre-change audit reported 398/440 supported default selections. Its remaining unresolved groups were off-hand weapon DMG, fixed BowEQ channels, two nonzero values tagged `EM_WearEqType_None` on item 211800, and four valid but mismatched vocation/sub-vocation IDs. Some of the gear reasons overlapped on the same NPC. After the changes, those 42 default fallbacks are resolved.
## Source-backed channels
`RoleAttr.cpp::AddAbility` routes type 25 to `BaseEQ.Main_DMG`, `BaseEQ.Sec_DMG`, or `BaseEQ.Bow_DMG` according to the main-hand, second-hand, or bow position (lines 1323, 1479, 1627). Type 25 on armor remains a generic body DMG contribution. The resolver now preserves the three weapon-position amounts independently and applies the NPC second-hand `Dmg_SecHand` coefficient when it produces `offHandDamage`. `RoleAttr.cpp::_SecDMG` defines that coefficient and its damage inputs (lines 766–806); `_Bow_DMG` defines the bow amount (lines 1124–1139). The generated NPC FixTable value for `Dmg_SecHand` is 0.7.
Selected off-hand and bow weapons also expose their source attack speed and range. Recognized armor type-23 attack-speed effects are applied to each weapon speed channel. A type-23 effect on an off-hand or bow weapon remains fail-closed because its position-specific behavior has not been established. Type 191 continues through the existing generic MDMG channel. No current imported NPC has an ornament item; any future non-empty ornament remains fail-closed until its slot-specific path is verified.
The source enum identifies wear index 0 as `EM_WearEqType_None` (`ObjectStruct.h:1427`). The two value-35 pairs on item 211800 are therefore treated as no-op channels, while the item's type-25 and type-191 rows continue to contribute normally. Its drop-ability metadata remains metadata and is not applied as an equipment affix.
Across all five choices for all 440 NPC records, the active effects are type 0, 3, 5, 8, 12, 13, 14, 22, 25, 150, and 191. No current NPC equipment record has an ornament item. Ten records have BowEQ, all now supported. Unknown nonzero wear types remain fail-closed; the resolver does not infer future effects from these present-day counts.
## NPC sub-vocation growth
The four corrected source identities are NPCs 100074 (vocation 4/sub 6), 100594 (6/1), 100611 (0/6), and 100614 (0/6). `RD_ValueCal.cxx::InitCal` clears a duplicate primary/secondary vocation (lines 114–118), then assigns a valid NPC `Level_Sub` from its primary level (lines 154–161). `RoleAttr.cpp` adds one tenth of the secondary vocation's full race/vocation/sex base curve for each base attribute and for HP/MP (lines 96–124). The resolver now applies that one-tenth curve to the five exposed attributes and derived HP. Primary vocation coefficients and damage rate stay primary-vocation values, matching the source's separate primary `Arg` path. Sub-vocation mana is not exposed by this NPC combat snapshot.
The regression test locks each corrected NPC's resulting five attributes and health to its source-derived values. NPC recovery inputs are not part of this change.
## Resolver API and limits
`romNativeNpcCombatant(npcId, runtimeLevel, equipmentSelection)` remains the integration entry point. Its result includes the exact `equipment` choice and selected candidate indexes. The outer result now has optional `offHandDamage`, `offHandAttackSpeed`, `offHandAttackRange`, `bowDamage`, `bowAttackSpeed`, and `bowAttackRange` fields when those source weapons are present. The embedded `nativeCombatant` and periodic snapshot stay separate from encounter health/damage budgets.
The fixed-bow rows currently have neutral source rate factors for all non-target bow modifiers. Rate effects that are not represented by the current equipment catalog remain unsupported and fail-closed if they occur. This pass does not wire equipment fields into combat scheduling, change authored loot, or alter player-stat resolution.
## Validation
Focused validation: `npx vitest run src/game/romNativeCombatResolution.test.ts src/game/romNativeNpcEquipment.test.ts` — 2 files and 18 tests passed. The population regression checks all 440 default records and all 11,000 explicit candidate-pair selections.
Read-only source references are under `F:\Projects\Runewaker\SourceCodeVS2019\Libraries\Union\RoleData`: `ObjectStruct.h`, `RD_Setting.cxx`, `RoleAttr.cpp`, and `RD_ValueCal.cxx`. The generated NPC equipment catalog already contains the required source candidate and item rows, so this completion needed no exporter or generated-data change.
@@ -0,0 +1,62 @@
# Native RoM NPC equipment pass — 2026-10-03
## Scope and behavior
This pass adds source-backed NPC WearEQ effects to the native Runewaker combatant path, capped at the repository's existing supported level 80. It does not connect equipment drops or loot. The WoW path and per-actor combat modes are unchanged.
The `export-rom-native-npc-equipment.mjs` exporter reads the NPC candidate lists and fixed BowEQ/OrnamentEQ values from `npcobject.db`, referenced rows from `weaponobject.db`/`armorobject.db`, and refine rows from `eqrefineabilityobject.db`. It preserves all five NPC main-hand/off-hand candidate slots at 20% each, including duplicate and empty choices. Runtime resolution is deterministic and does not average candidate stats:
```ts
romNativeNpcCombatant(npcId, runtimeLevel, {
selectionSeed: stableActorOrEntityId,
// Optional source candidate overrides:
mainHandCandidateIndex,
offHandCandidateIndex,
});
```
Use an actor-unique stable seed when separate live copies of one NPC should roll independently but reproducibly. Omitting the seed defaults to `npcId`; supplying a candidate index pins that source slot. The resolved equipment and selected candidate indices are included in the combatant result.
The source NPC spawn initialization sets item count to one, durability to 10000, quality to 100, power quality to 10, rare to zero, and item level to `min(RefineLv, 20)`, while selecting the main-hand candidate with `rand() % _MAX_NPC_EQSEL_COUNT_` (`RD_Setting.cxx:2671–2681`). `WearEQ` computes durability power tiers and applies power quality (`RD_ValueCal.cxx:1138–1167`), reads weapon attack range/speed and main-hand values (`1186–1194`), then applies refine rows and the rare-dependent extra-equipment rate (`1212–1272`). Refine lookup is item refine-table ID plus level minus one (`3743+`). These source paths drive the exported defaults, equipment choice, wear power, item weapon speed/range, and refine contributions.
Known nonzero `WearEqType` values are translated only where the existing native snapshot has a source channel: attributes and rates, max HP, physical/magical attack and defense, hit/dodge/critical/parry, main-hand attack speed and damage, body physical damage, magic damage, healing, resistance amounts/rates, and typed magic-power rates. The enum identifiers/order are in `ObjectStruct.h:1425+`; `RoleAttr.cpp` verifies base main-hand speed 30, final DMG/MDMG/main damage, and the separate Throw_DMG calculation (`184`, `288–314`, `645`, `655`, `713`, `1201–1204`). `nativeCombatant.throwDamage` carries the derived neutral Throw_DMG input for exact source skill formulas that require it. Unknown nonzero types fail closed. Drop ability IDs/rates are kept as explicitly named drop metadata and are not applied as wearable stats.
## Coverage and limits
The imported population has 440 NPC rows. Under the stable default NPC-id seed, 398 resolve to a supported native snapshot. The remaining 42 do not silently receive neutral equipment: 26 selected off-hand weapon damage cases lack a modeled secondary-weapon damage channel; 10 rows have BowEQ/OrnamentEQ channels not represented by the current snapshot; two rows select item 211800 with unknown nonzero WearEqType 0/value 35; and four NPCs (100074, 100594, 100611, 100614) have a vocation-sub-ID mismatch that prevents deriving the existing native stats snapshot.
Across the 112 distinct referenced equipment items, no item ID is missing. There are 96 items with nonzero WearEQ ability entries; 95 have fully mapped active entries, while one has the unresolved type 0 entry above. Referenced source item effect types are 3 (STA), 5 (MND), 8 (MaxHP), 12 (ATK), 13 (DEF), 14 (MDEF), 22 (parry), 25 (DMG), 150 (HealPoint), and 191 (MDMG), in addition to zero/empty slots. The resolver also implements the additional enum channels listed above for source completeness, but does not claim every enum has been validated against a currently equipped item. Twenty-two items have nonzero `DropItemAbility` metadata; those records are not WearEQ affixes and are not wired here.
The exporter captures refine metadata and the resolver applies a verified refine row sequence when an imported item has a refine level. Current referenced NPC gear has no item with a positive refine level, so the refine application branch is source-implemented but not exercised by present population data. Inherent attributes and runes are empty in the source NPC spawn defaults. Bow/ornament wearable effects and off-hand weapon DMG/speed remain unsupported at combatant construction; no unknown effects are discarded. The source factory's four vocation-sub-ID mismatches remain unchanged by this equipment pass.
The Barren Caves targets are supported with their exact source equipment outcomes:
| NPC | Source equipment | Source durable / attack speed | Resolved wear power |
| --- | --- | --- | ---: |
| 101459 | Main hand 210056 | 70 / 20 | 1.2 |
| 101460 | Main hand 210061 | 55 / 20 | 1.2 |
| 101461 | Off hand 210666 | 1426 / 20 | 0.2 |
These items have no active WearEQ ability entries, so they preserve the source neutral combat stats while replacing the previous neutral main-hand speed fallback of 30 only when a real source weapon provides its own speed. An independent positive-effect check covers item 210048 (NPC 100606, main-hand candidate 0): source DMG 213 and MDMG 167 contribute at the source 1.2 wear power. NPC 106315/item 213019 also verifies STA, parry and low durability power (0.2).
## Generated source snapshot
`src/game/generated/romNativeNpcEquipment.generated.json` is tied to these local source hashes:
| File | SHA-256 |
| --- | --- |
| `npcobject.db` | `b627b6eb269c30576a6558000a31abe2db472689ef9a60cc1c1e20207abd69f1` |
| `armorobject.db` | `ccdd438219b801680e4f7c8354171d5347dcab5fcf581f554c39eba3543d961a` |
| `weaponobject.db` | `ca58aa607388c4df2fc1a5c6030b1552c2eecb90082fe381cd836aabf6fe467b` |
| `eqrefineabilityobject.db` | `724a469d095466df55eb72264ee746a9c6fca866eb76fcb500270cfee3d02f43` |
## Validation
Focused tests run:
```text
npx vitest run src/game/romNativeNpcEquipment.test.ts src/game/romNativeCombatResolution.test.ts
16 tests passed
```
The tests preserve the Barren candidate arrays and weights, check deterministic and explicit candidate selection, check exact item WearEQ power, verify mapped nonzero DMG/MDMG and attributes/parry effects, ensure unknown effects fail closed, and assert that bow/ornament and invalid candidate paths remain unsupported. Broader app validation is handled by the parent integration task.
@@ -0,0 +1,31 @@
# RoM native periodic damage source pass — 2026-10-03
This pass adds a source-bound first-application snapshot and per-tick amount helper for negative HP `DotMagic` effects. It covers the active five RoM builds and deliberately leaves resource drains, healing-over-time, positive-base effects, unsupported refresh behavior, and unverified NPC stat derivation to their separate paths.
The exporter reads the sibling `Runewaker` tree without modifying it. `scripts/runewaker-pipeline/export-rom-native-periodic-damage.mjs` binds exact `MagicCollect` skill IDs to the active player catalog and imported NPC combat profiles, then emits `src/game/generated/romNativePeriodicDamageBindings.generated.json`. The active five contribute 18 ability rows, 18 source-rank rows, and 14 unique negative HP/HP_Per DotMagic components. The imported NPC source table has 440 NPC rows and 446 population-entity mappings; 50 NPCs currently have bound negative HP/HP_Per DotMagic, with 50 source-rank rows and 42 unique components. The union has 56 unique component IDs. NPC tick sources preserve the assigned slot MagicLv and use the verified native NPC growth upper bound of 200; this does not imply that every NPC stat profile is ready for native resolution. The JSON records the raw source IDs, selected fields, filter, record offsets, and SHA-256 hashes for the source databases/code and local catalogs.
## Application and tick order
`MagicProcess.cpp` passes the effective cast `SpellTime` and `MagicProc.MagicLv` to `CalAddDotMagicLv` when applying an assist effect. The rank helper uses `DotTimes = max(1, int(MagicBase.EffectTime / Dot.Time))` from the raw MagicBase duration; this is distinct from the effect duration later computed for the applied buff. For a negative HP DotMagic row, the correction first builds `DotBasePower = Dot.Base * (1 + MagicLv * DotSkillLVArg / 100)`. `FixDotPower` returns no correction, and a zero `DotSkillLVArg` also returns no correction.
For magical effects the source adds or subtracts caster MDMG scaled by the player/NPC `ExRate`, cast `SpellTime`, `FIX.MDmg_DotArg`, and `DotTimes`. If the resulting power is negative, the source applies the target's `CalResistDMG` to the aggregate power across `DotTimes`, divides back by `DotTimes`, then applies the source `(MATK + 100)/(MDEF + 200)` power rate. If it becomes nonnegative, it enters the source heal-power branch and multiplies by `MagicPow(MagicType)`. Physical effects instead use `Main_DMG`, `Main_ATKSpeed`, `FIX.MDmg_DotArg`, and `(ATK + 100)/(DEF + 200)`; they do not call magical resistance. The returned correction is `int((FinDmg - Dot.Base) / (Dot.Base * DotSkillLVArg / 100) - OrgMagicLv)`.
`MagicProcess` adds this correction to the cast's `MagicLv`, then forces it to zero when `ShowPowerLv` is set. It computes applied duration from the base effect time, effect-time skill argument, and caster-target level difference. `AssistMagic` then applies the source maximum-skill-level and `BuffMaxLv` caps. The helper requires the actual effective `MagicLv`, caster and target levels, and exact source-derived combatant inputs instead of guessing a spell rank or a missing weapon/resistance value.
`RD_ValueCal.cxx::BuffProc` emits a tick when elapsed buff time is divisible by `Dot.Time`. Each tick starts with `int(-Dot.Base * (100 + DotSkillLVArg * Buff.Power) / 100)`. `HP_Per` then multiplies by the target's current `Fin.MaxHP / 100` and truncates again. The source dispatches its periodic special-attack event before the PK adjustment and HP shield/HP application; magical DoTs use the `magic` event and physical DoTs use the `skill` event. `MagicShieldAbsorbProc` runs before HP application. `DotMagicNoKill` keeps the target at 1 HP when applicable. The tick reuses stored `Buff.Power`: it does not reroll a critical hit or reapply armor/resistance. The pure tick helper returns the integer amount and event/no-kill metadata; the shared combat runtime remains responsible for shields, health application, and side effects.
The runtime API in `src/game/romNativePeriodicDamage.ts` separates application snapshot inputs from tick inputs. Elemental magical rank correction requires the target's exact resistance channels and caster's effective `MagicPow(MagicType)`. Physical rank correction requires exact `Main_DMG` and `Main_ATKSpeed`. NPC callers must provide the NPC's exact `FIX.MDmg_DotArg` and source-bounded caster/MagicLv limits; only the source player value is taken from generated verified rules. Player builds remain capped at level 80. Enemy lookup accepts the MagicCollect `spellId` and exact component ID, and the NPC source list is limited to imported encounter NPC spell bindings. An existing-stack reapplication is unsupported because `AssistMagic` can retain or raise the existing buff power. Unsupported inputs return a reason rather than a substitute amount.
## Active-five special fixed inputs
The active-five direct-damage binding audit found 66 negative `Attack.DmgPower` components. Of these, four use fixed type 4 (`Throw`): Rogue source skills 490338 and 490309, components 502578 and 620283, each with `FixValue=-3`. Three use fixed type 12 (`ShieldDef`): Knight skill 490175/component 500175 and skill 490186/components 500177 and 500185, each with `FixValue=-0.1`. No active-five negative direct component uses type 10 (`LastSkillDmg`), type 11 (`LastPhyDmg`), or the separate calculation-type Throw branch.
`MagicProcess.cpp` evaluates each fixed input as `Owner->GetValue(FixType)`, so the fixed source belongs to the caster. `RD_ValueCal.cxx::GetValue` resolves type 4 to caster `Fin.Throw_DMG`, type 10 to caster `BackInfo.LastSkillHitPoint`, type 11 to caster `BackInfo.LastPhyHitPoint`, and type 12 to caster raw `BaseEQ.Def[EM_Armor_Shield]` (not the target's shield or total defense).
`RoleAttr.cpp::_Throw_DMG()` is `max(0, (Fin.DMG + BaseEQ.Throw_DMG) * BaseEQ.Throw_DMG_Per * BodyRate.DMG_AllRange * BodyRate.DMG_Per)`. Without throw equipment, source initialization leaves `BaseEQ.Throw_DMG=0` and `Throw_DMG_Per=1`, but `Fin.DMG` itself already applies its DMG_Per factor, and `_Throw_DMG` applies DMG_Per once more plus the AllRange factor. A single generic physical-damage stat is therefore not enough to derive this input. Runtime should supply verified caster `Fin.Throw_DMG` explicitly or leave these four components unsupported.
Last-hit channels are per caster. `MagicProcess` clears `LastSkillHitPoint` at cast start and sets it from signed `int(Temp.Value)` after supported skill effects. The auto-attack scheduler clears `LastPhyHitPoint` before the attack sequence and stores `Damage * -1` after each attack. Since neither type occurs in the active-five negative direct bindings, no persistent prior-hit channel is needed for those current rows.
## Focused validation
`src/game/romNativePeriodicDamage.test.ts` checks source lookup, magical and physical rank correction, cast duration versus raw rank-correction duration, fixed/show-power rows, HP_Per tick scaling, no tick-time critical reroll, fail-closed inputs, and reapplication handling. `src/game/romNativePeriodicCombat.integration.test.ts` exercises a real player Vampire Arrows application, an active-five Priest/Knight Bone Chill companion application with native magical defense/resistance and the required typed MagicPower inputs, per-actor RoM versus WoW fallback, and an imported NPC HP_Per DoT whose later tick reads the target's current MaxHP. The focused helper/integration command passed all five tests.
@@ -0,0 +1,68 @@
# Native RoM recovery, equipment and pacing follow-up — 2026-10-03
## Source recovery
Native player and owned-party NPC HP/Mana now use the verified RuneWaker ReHP/ReMP inputs. Each actor keeps its own ruleset, attributes, gear and secondary class; saved WoW-mode RoM profiles keep compatibility behavior. Player pulses occur every two seconds, owned NPC pulses every six. Combat recovers no HP and ReMP/24 Mana per pulse; idle standing players use ReHP/ReMP divided by three and idle owned NPCs use 0.66 times those values. Source aura effects are sampled at each pulse, including expiry boundaries. Explicit periodic Mana effects remain separate.
The source audit and limitations are in [the recovery report](ROM_NATIVE_RECOVERY_SOURCE_2026-10-03.md). There is no sitting control or simulator StopOntimeCure state, and combat detection approximates the source attack-mode flag. Stopping companions while an enemy remains engaged does not grant idle recovery.
All five active native companion identities, secondary Mana, and WoW compatibility are covered by `romNativeRecoveryParity.integration.test.ts`. The original focused recovery selection passed nine tests; the additional parity selection passed three.
## Imported NPC equipment
All 440 imported NPC defaults and 11,000 explicit main-hand/off-hand candidate pairs now resolve supported native snapshots. This closes the previous 42 default fallbacks with source position-specific weapon channels, the explicit type-zero no-op, and valid secondary vocation growth. Unknown effects and future non-empty ornaments remain fail-closed.
Off-hand and bow damage/speed/range are available in snapshots; this does not schedule extra enemy swings. No exporter changes or loot wiring were needed. See [equipment completion](ROM_NATIVE_NPC_EQUIPMENT_COMPLETION_2026-10-03.md).
## Compatibility pacing
The four legacy failures are resolved with boss health budgets while retaining the original 25-second ordinary and 45-second final minimum checks. WoW starter health floors are 1,800 at enemy level 2 and 2,600 at level 4. RoM-origin WoW-mode boss health has a separate compatibility multiplier. Native source amounts and actor defaults remain independent.
All 83 targeted compatibility timing samples now pass, including all 12 level-80 RoM seeds. The focused scaling selection passed 18 tests and the additional native/bite-sized pacing selection passed six. Exact native level-8 and level-80 health/damage-budget assertions guard separation. Full pre/post timing evidence is in [the pacing report](LEGACY_BOSS_PACING_FIX_2026-10-03.md).
## Source recovery pressure measurements
Six focused pressure tests passed, including 16 real-source Anglo pulls at levels 10/30/60/80 with scripted spread/stack positions, active/idle healer comparisons, movement, interrupts and player/NPC dispels. All sixteen pulls clear without deaths. Native source recovery lowers minimum active-healer Mana at level 10 from approximately 95–97% to 77–85%, and at level 30 from 96% to 87–90%. Levels 60/80 still retain approximately 98–99% Mana. This records the remaining high-level sustain limitation instead of claiming a challenging Mana budget across all levels.
Reports are preserved under `artifacts/rom-enemy-followup/`; these artifacts are diagnostics rather than readiness certification. The historical 80-pull cohort used shared regeneration. Its clear times cannot establish current native source sustain.
## Early-level sustain correction
The source-recovery rerun exposed a real early-level regression: the level-one Knight/Anglo sample took 141 seconds with a player and Rogue NPC death, compared with the preserved 54-second shared-regeneration baseline. Holy Strike's manual effect rewrite had lost its weapon delivery classification, so its Holy school prevented starting free auto-attacks. Explicit source attack-kind metadata now preserves the same 16-point Holy direct hit while starting the 22-point physical training swing. The focused cast-to-auto regression passes; no weapon-hand amount override or source recovery multiplier is used to hide the issue.
The level-one NPC Priest has 123 Mana and ReMP 4.8, recovering only 0.2 Mana per six-second combat pulse. Its shared fallback initially chooses 12-Mana Rising Tide while Urgent Heal costs 25. The early native-healer decision policy reserves one learned direct heal before spending Mana on hostile damage. This is a clean-room simulator rotation policy, separate from source recovery and spell amounts.
The reserve preserves its spending boundary; it does not by itself resolve the failed fight. After both runtime fixes, the 0.30 native level-one boss factor still kills the Knight and Rogue NPC. The measured training budget is now 0.20 at level one, leaving trash at 0.40 and every anchor from level 10 onward unchanged. Levels 2–9 interpolate smoothly to the unchanged level-10 factor. Anglo's level-one pool becomes 1,848 HP; the Knight clears in 42.1 seconds with 95.6 HP and no companion deaths. The five level-one player seats clear Anglo in 34.1–42.1 seconds. Source recovery, damage formulas and damage-budget anchors remain unchanged.
The unchanged fixed diagnostic now passes all 80 player/four-NPC pulls at levels 1/10/30/60/65/66/79/80: zero player/NPC deaths and zero invalid casts. Trash takes 4.9–8.7 seconds and Anglo 34.1–58.5 seconds. Static positions do not establish full scene navigation.
## Focused verification
The final source/recovery/resource/damage-type selection passes 156 tests across 24 files, including all 40 fixed/MaxHP actor cases and the 16-pull pressure report. The separate 80-pull diagnostic and five native pacing checks pass. The exact legacy suite passes all 18 checks again after the source-recovery and reserve changes; its 83-sample final report is `artifacts/rom-enemy-followup/after-compatibility-pacing-post-recovery.json`. Ready-five passes seven Vitest and twenty Python cases; avatar checks pass 47 tests. These selections overlap and should not be added as unique test counts.
An additional `combatRotation.test.ts` run passes 30 of 31 checks. Its existing representative-signature assertion fails for Rogue Killing Spree because that catalog entry has only unsupported/scripted/utility effects. The assertion does not exercise the new native reserve; this unrelated mechanic was not implemented or its check weakened. The four new reserve checks pass, including the real benchmark fallback gate, exact 36/37-Mana boundary, effective-cost override and valid/invalid heal targets.
The 8 GB app TypeScript check and final `npm run build` pass, including bootstrap generation, texture/performance audits, TypeScript/Vite, static compression and release-bundle verification. The performance audit reports no new regressions; existing chunk-size and static/dynamic-import warnings remain. Final chunks are `index-DzLgXnx0.js`, `combatStore-Dh-12M9R.js`, `GameRuntime-C95Du8PI.js` and `PartyFrames-Cf7FSiBA.js`.
## Full selected production encounters
`scripts/playtest-native-combat.cjs --full-fights` uses an isolated local account and the exact final production build. A UI-created level-30 native Priest/Knight healer joins the four other native identities. The selected Boss Testing scenes keep original attack lists/cooldowns; level and class pairing are test setup. Keyboard action bindings perform healing, cleanses and counters without direct damage, HP/Mana or healing edits during fights. These reduced testing-room budgets are separate from campaign durability.
| Selected scene | Clear time | Player Mana minimum / end | Survival |
| --- | ---: | ---: | --- |
| Anglo, RuneWaker NPC 101462 | 39.9s | 88.7% / 88.9% | Player and all four NPCs alive |
| Lady Anacondra, WoW creature 3671 | 16.5s | 91.0% / 91.0% | Player and all four NPCs alive |
There are no page errors. Anglo retains Physical Trample/Bash and its magic-dispellable Gulp control; Lady retains Nature-school spell Lightning Bolt and her original Druid's Slumber. Source-ID namespaces remain separate. Both Anglo cast cancellations were observed before their scheduled completion alongside real healer counter inputs. Gulp clears from the tank 1.85 seconds before source expiry after a cleanse input; the Rogue's separate Gulp expires naturally. Lady's Mage sleep clears 4.74 seconds before expiry after a cleanse input. Lady's single Vow of Silence attempt did not show a Lightning Bolt cancellation, so this run does not establish that particular cross-source interrupt behavior.
The full fights record zero movement inputs; the ranged healer remains outside the melee warning. Movement is checked separately in a labeled staged source-warning fixture, rather than being attributed to those full fights.
The same-final-build staged Anglo selection passes all three source fixtures, each releasing once, with no page errors. Trample spell 492292 retains its 1.8-second cast, five-unit radius, Physical school, effect and warning. Fixture setup selects one source attack, resets readiness/positions/threat and sets repeat cooldown to 60 seconds; companion actions are paused for the movement warning. Real W-key movement covers 2.75 units while the cast remains live, from 3.93 inside the radius to 6.69 outside. The player receives zero Trample damage while a nearby Rogue receives 262, proving recipient exclusion rather than suppressing the entire packet. Bash and Gulp also release once. Telemetry is `playtest-artifacts/native-combat/results.json`; the warning and movement images are `anglo-trample-warning-fixture.png` and `anglo-trample-movement-fixture.png`. Both were visually reviewed.
Full-fight telemetry and screenshots are in `playtest-artifacts/native-combat/full-fights-20261004-024033763/`. Root visual review confirms the roster player's body/staff, loaded player and party bodies with held weapon/shield/staff/bow silhouettes in the mid-fight capture, readable native recovery/Training gear equipment UI, and the defeated-boss result. Initial NPC loading placeholders resolve by mid-fight; they are not claimed as the loaded visual proof. No readiness review or dungeon report was submitted.
The harness syntax check, repository release check (including untracked files), repository/vault diff checks and edited-vault-note EOF/whitespace checks pass. These selected desktop Boss Testing scenes and scripted diagnostics do not certify every encounter, mobile UI, campaign traversal or readiness.
## Validation boundary
This is targeted work on the five active RoM builds and the current WoW Ready five. RoM builds remain Needs Testing. Cap 80, actor-local compatibility switching, automatic Training gear and the current loot scope remain unchanged. No all-class sweep, readiness promotion, commit or deployment is part of this task.
@@ -0,0 +1,40 @@
# Native RoM recovery source audit
## Implemented scope
Native RoM recovery now uses RuneWaker's ReHP/ReMP values and actor-specific pulse cadence for exact RoM-mode player builds and owned party NPCs. Player and companion stats remain actor-local: a companion uses its own class, secondary class, level, race, gear, and `statRuleset`. The native path only activates for a RoM class whose actor ruleset is `rom`; saved WoW-mode RoM characters and ordinary WoW actors keep the existing shared resource behavior.
The source-derived stat snapshot is already generated by `scripts/runewaker-pipeline/export-rom-native-stat-rules.mjs` into `src/game/generated/romNativeStatRules.generated.json`. `src/game/romNativeStats.ts` computes ReHP from its source base, stamina coefficient, and mapped gear/aura channel; ReMP uses its source base, wisdom coefficient, and mapped gear/aura channel. The five active build stat profiles use BaseReHP 2, ReHpArg 0.15, BaseReMP 0, and ReMpArg 0.3. The character's supported Mana eligibility is preserved, including a Mana secondary pool when the selected primary resource is not Mana.
## Source behavior
`F:/Projects/Runewaker/SourceCodeVS2019/Server/NetGameServer/MainProc/Global.cpp` contains the source player update at lines 1956–1984 and owned-NPC update at lines 2335–2363. Player recovery checks every `ClockMod(20)` and owned NPC recovery every `ClockMod(60)`, corresponding to 2-second and 6-second pulses in the source server clock. Dead actors and actors with `StopOntimeCure` set are skipped. Recovery only runs when integer current HP or MP differs from its maximum.
For players, `IsAttackMode()` restores no HP and restores `ReMP / 24` Mana per pulse. Out of attack mode, standing recovery restores `ReHP / 3` and `ReMP / 3`; sitting uses `0.66 * ReHP` and `0.66 * ReMP`. For owned NPCs, attack mode likewise restores only `ReMP / 24`; idle recovery restores `0.66 * ReHP` and `0.66 * ReMP`. The separate unowned-NPC branch uses max-resource fractions and is not the source behavior for party companions.
`F:/Projects/Runewaker/SourceCodeVS2019/Libraries/Union/RoleData/RoleAttr.cpp` lines 525–531 defines:
```text
ReHP = max(0, BaseReHP + Fin.STA * ReHpArg + Mid.Body[ReHP])
ReMP = max(0, BaseReMP + Fin.MND * ReMpArg + Mid.Body[ReMP])
```
The resulting values are copied to `Fin.ReHP` and `Fin.ReMP` in the same file at lines 279–280. `RD_ValueCal.cxx` lines 1512–1552 (`AddHP`) and 1584 onward (`AddMP`) apply float amounts to the stored resources and clamp to their maxima, so simulator pulse values remain fractional instead of being rounded to whole points.
RoM Aura components mapped to source ReHP/ReMP channels are included in the per-pulse source stat snapshot. Other generic or unmarked regeneration modifiers do not enter the native recovery calculation. Explicit `rom-resource-over-time-*` Mana effects remain a separate simulator flow and are integrated over the effect's actual active interval; they are not added to ReMP. WoW's five-second mana spending rule and out-of-combat minimum do not apply to the native ReMP path.
## Runtime ownership and validation
`src/game/romNativeRecovery.ts` owns the 2-second player and 6-second owned-NPC pulse clocks, fractional source formulas, sitting coefficient, and separate explicit periodic-Mana interval integration. The clocks preserve fractional elapsed time across update partitioning and clear when actor kind/ruleset changes. `src/game/combatStore.ts` owns the player pulse consumer; `src/game/partyRuntime.ts` owns the companion consumer; `src/game/partyStore.ts` clears companion recovery timing on lethal damage, revive, and combat reset. Aura records are sampled at each pulse timestamp, so an aura expiring after a pulse still contributes to that pulse.
On the player path, source attack mode is approximated by the existing engaged-combat signal. For owned party NPCs, the source gate includes an active party target or a live engaged/threatened enemy, so the party's Stop command alone does not switch a companion to idle recovery. Native HP/Mana bypass shared HP5/Spirit, passive generic Mana, and the shared out-of-combat Mana floor. Other resource types continue through their existing resource model.
Focused validation is `npx vitest run src/game/romNativeRecovery.test.ts src/game/romNativeRecovery.integration.test.ts`; the parent task adds active-five NPC, secondary-Mana, and WoW-mode compatibility integration cases. Source and simulator limits remain: there is no sitting UI/state, no simulator equivalent for source `StopOntimeCure`, and player/party combat-mode detection is an approximation of source `IsAttackMode`. Native rates are source-derived; this audit does not certify sustained encounter pressure or change encounter budgets/readiness.
## Early level-one sustain diagnostic
The first native Knight level-one pull against Anglo ended after 141 seconds with the player and Rogue companion dead: Anglo's 2,772-HP pool was depleted, but incoming damage was 391.2, healing was 56, player damage was 118 across 6 casts, and 44 rotation decisions were resource-blocked. The pre-recovery/equipment baseline cleared the same pull in 54 seconds with 157.6 player HP remaining, 375 player damage across 19 casts, 262 healing, and 276 incoming damage. These two observations identified an additional attack-start and resource-rotation gap; they do not justify changing the source ReHP/ReMP formulas, and are not a full-cohort result.
Source Holy Strike `490148` is documented as a main-hand attack with Physical Light Damage. The imported direct hit was already level-one 16 holy damage, but its nonphysical damage school caused the generic attack-kind classifier to treat it as a spell; a cast therefore did not start the player's auto-attack. A separate `sourceAttackKind: "melee"` annotation now represents source delivery without changing effect school or amount. The focused regression confirms the cast remains 16 holy damage and is followed by a positive 22 physical auto-attack.
The level-one Priest observation showed max Mana 123 and ReMP 4.8, which gives an owned-NPC combat recovery pulse of 0.2 Mana every six seconds. At full party health its chooser selected 12-Mana Rising Tide despite having the 25-Mana Urgent Heal, confirming a finite-Mana rotation concern independent of recovery-rate math. A separate clean-room pre-level-10 healer policy reserves one learned direct heal before hostile Mana spending; it keeps source recovery unchanged. That reserve alone did not resolve the failed early encounter, so the native level-one boss training budget was separately reduced from 0.30 to 0.20. The unchanged final 80-pull cohort passes with zero player/NPC deaths and invalid casts. See [the aggregate verification and limits](ROM_NATIVE_RECOVERY_EQUIPMENT_PACING_PASS_2026-10-03.md).
@@ -0,0 +1,55 @@
# RoM Native Party Campaign Route Playtest — 2026-10-04
## Scope and build
Tested bounded entrance-to-encounter segments in Barren Caves and Wailing Caverns using the ordinary campaign picker. Each run used a UI-created level-30 RoM Priest / Knight healer and four active RoM NPC companions. Both dungeons entered in healer role with `gameMode=dungeon`, `gmTestMode=false`, and no Boss Testing state. Only initial profile level and secondary class were prepared for the isolated test profile. Dungeon entry, WASD movement, Tab targeting, party commands, ability keys, and ally selection used the game UI and normal inputs. Combat health, mana, damage, positions, cooldowns, enemy health, and targets were not changed by the harness.
The confirmed final production build used `index-CAMDJqEC.js`, `combatStore-t7bO4yKt.js`, `GameRuntime-otPnJCWI.js`, `PartyFrames-BsBsesPR.js`, and `index-499eecpe.css`.
## Roster Continue hit test
On the final build, the actual Continue button was clickable normally at both tested sizes. The 1440×900 layout and the 390×844 compact layout had no overlap with the floating toggle, and `elementFromPoint` at the button center hit Continue in each. The run recorded `forceClickUsed=false`.
- [Desktop Priest/Knight roster](../playtest-artifacts/native-routes/final-20261004-052623521/roster-priest-knight-level-30.png)
- [Compact Priest/Knight roster](../playtest-artifacts/native-routes/final-20261004-052623521/roster-compact-priest-knight-level-30.png)
- [Hit-test measurements and full run result](../playtest-artifacts/native-routes/final-20261004-052623521/results.json)
## Route results
| Campaign segment | Input and endpoint | Support and combat outcome |
| --- | --- | --- |
| Barren Caves, 90-second segment | 41 WASD movement steps; 68.13 m summed keyboard displacement and 69.34 m game distance; ended at “Uncharted Barren Caves.” | 15 effective player heals (6,154.63 total) and 7 effective player damage events (549 total); no party deaths. The level-30 profile naturally reached level 31. The endpoint view is partly blocked by a cave pillar, so this run demonstrates bounded movement and combat, not camera visibility or full navigation. |
| Wailing Caverns, fresh level-30 profile, 90-second segment | F1 Attack then F2 Defend; 15 WASD movement steps; 23.12 m summed keyboard displacement and 23.76 m game distance; ended at “The Winding Chasm - Entrance.” | 11 effective player heals (6,831.49 total), zero effective player damage events, and no party deaths. Three distinct entrance mob records were observed dead across snapshots: Deviate Guardian, Deviate Ravager, and Druid of the Fang. This does not claim a full route or total dungeon kill count. |
The Wailing run selected the longest-range bound hostile ability, Rising Tide (21.7 m), and used Urgent Heal (25 m). The first Wailing diagnostic had kept the party and healer too far apart and lost Rorik; that trace is preserved at `final-20261004-051737171` as a harness-policy diagnostic. The final run sampled live NPC positions, waited for healing casts to resolve, and issued F2 Defend after the initial F1 pull. Rorik survived this bounded segment, but the run produced no effective player damage, so it provides no player-DPS coverage.
The observed school list is from the combined combat event stream, including player and NPC activity; it does not establish that each enemy used every listed school. `killedMobNames` describes dead mobs still present in the endpoint snapshot. Wailing's three names above are the distinct dead records sampled during the segment, not a complete kill ledger.
## Evidence
**Barren Caves** — completed route record and screenshots:
- [Route JSON](../playtest-artifacts/native-routes/final-20261004-051413627/barren-caves-route.json)
- [Campaign picker](../playtest-artifacts/native-routes/final-20261004-051413627/barren-caves-campaign-picker.png)
- [Entrance](../playtest-artifacts/native-routes/final-20261004-051413627/barren-caves-entrance.png)
- [Keyboard traversal](../playtest-artifacts/native-routes/final-20261004-051413627/barren-caves-keyboard-traversal.png)
- [Encounter midpoint](../playtest-artifacts/native-routes/final-20261004-051413627/barren-caves-encounter-midpoint.png)
- [Segment endpoint](../playtest-artifacts/native-routes/final-20261004-051413627/barren-caves-segment-end.png)
The combined Barren/Wailing attempt has a follow-on `failure.json`: after Barren naturally advanced the profile to level 31, a harness assertion expected level 30 before starting the second route. The Barren route JSON and its screenshots are complete and are reported independently. Wailing was then run in a fresh level-30 profile.
**Wailing Caverns** — independent final route record and screenshots:
- [Run summary](../playtest-artifacts/native-routes/final-20261004-052623521/results.json)
- [Route JSON and detailed movement/heal records](../playtest-artifacts/native-routes/final-20261004-052623521/wailing-caverns-route.json)
- [Campaign picker](../playtest-artifacts/native-routes/final-20261004-052623521/wailing-caverns-campaign-picker.png)
- [Entrance](../playtest-artifacts/native-routes/final-20261004-052623521/wailing-caverns-entrance.png)
- [Keyboard traversal](../playtest-artifacts/native-routes/final-20261004-052623521/wailing-caverns-keyboard-traversal.png)
- [Encounter midpoint](../playtest-artifacts/native-routes/final-20261004-052623521/wailing-caverns-encounter-midpoint.png)
- [Segment endpoint](../playtest-artifacts/native-routes/final-20261004-052623521/wailing-caverns-segment-end.png)
Both route runs reported zero browser page exceptions. Each recorded one console-level 404 for the local `/favicon.ico` request. The Wailing run also records normal keyboard events, party positions, health and mana at entry and endpoint, cast outcomes, and sampled enemy deaths.
## Harness
`node scripts/playtest-native-routes.cjs --route=<barren-caves|wailing-caverns> --route-seconds=90` runs an independent UI-created profile and one bounded segment. The default records both routes sequentially and carries natural level progression into later entry. Run and review each route independently to control its starting profile at level 30.
@@ -53,3 +53,15 @@ At the shared combat boundary, runtime may use numeric native ATK as common atta
## Focused verification
`romNativeStats.test.ts` checks every class/race/gender primary curve, player-derived level-1 channels, MP gating, source healing coefficients, the 148/191 conversion boundary, a real level-70 Mage reference curve, rate-preserving aura recomputation, and gear aggregation. `equipment.test.ts` checks per-item payload precedence, foreign direct-stat bridging, mixed-loadout behavior, and native equip/unequip impact. `itemStats.test.ts` checks that native payload persistence does not change the existing WotLK numeric fields.
## Combat amount follow-up (2026-10-03)
The earlier per-spell boundary above is now partially bridged by `src/game/romNativeCombatAmount.ts`, `romNativeHealing.ts`, and `romNativeDamage.ts`. `romAbilityCatalog.ts` attaches a source skill/component key to supported native effect amounts; the runtime resolves that key against generated RuneWaker binding data. Direct HolyHeal calculation type 5 uses the source OrgPower/MagicLv and caster MND, MDMG, MPow, HealPowerRate, HealPoint inputs. The currently selected MagicLv is the character level clamped to 1–80, an explicit simulator policy rather than a source rank curve. This amount uses mean variance; the ordinary shared healing resolver continues to handle the common critical and overheal result.
The direct HolyHeal caster amount excludes target absorption fields; the source HealAbsorbRate and flat HealAbsorb are applied once for each actual recipient. DotMagic healing has its own source rank correction at application and stores its integer buff/tick amount for later pulses; player and NPC recipients reuse that same snapshot rather than reapplying the amount formula. It is not routed through the direct HolyHeal equation. The present all-magic MPow value does not establish missing element-specific BodyRate values. Unbound or unsupported components still use the shared simulator fallback.
The current direct-damage route accepts only supported active-five source components with a matching canonical training-weapon payload. `MagicAttackCalENUM` fixed types 0, 1, 2, 3, and 5–9 are implemented from the checked source enum; throw, prior-hit, and shield-category branches remain unsupported because the sidecar lacks their exact inputs. The consumer also declines `isShareDamage` components and components with nonzero `decreaseArg` on nonzero `rangeType` until exact runtime target-count and target-ordinal context is wired; the pure helper's support for these fields is not end-to-end area-damage support. Reflected Shot has a regression for this fail-closed boundary. Custom/unverified native equipment, missing source items/components, and unavailable school inputs keep the shared fallback. The returned ability amount stops at mean outgoing source power before source weapon variance, native hit/avoidance, and native target-defense handling; the existing shared target resolver remains the simulator bridge.
`nativeTrainingAutoAttackProfile` provides native physical weapon damage and intervals for player Knight main-hand attacks and NPC Knight/Rogue melee plus Scout Bow attacks; NPC Rogue offhand is also covered. Player Rogue offhand handling requires an explicitly present equipped inventory item. Player Scout Shot uses the native direct-damage route for its actual bound Shoot hit, not a continuous native bow-auto profile. Caster ambient basic-spell attacks keep the generic clock and MDMG policy. Player melee timing reads the existing common haste aura through `resolveAuraStatValue`, since native stat re-derivation does not populate the shared haste fields; the existing race-affix timing is retained. The final source-damage selection passed 13 tests: seven helper cases, two player/companion amount-parity and Reflected Shot fail-closed cases, and four actual auto/source-action cases. Native direct/periodic healing passed 14 player/NPC cases. These routes do not complete native hit/avoidance, armor categories, weapon variance, or critical resolution.
The final fixed player-rotation/native-companion diagnostic covers 80 pulls across the five requested builds at levels 1/10/30/60/65/66/79/80. All 80 targets died, with zero player/NPC deaths and zero invalid cast failures; trash took 4.5–9 seconds and Anglo 39–53.4 seconds. Native-only enemy health pacing is scoped separately from legacy WoW-stat RoM and other character origins; details and the exact bands are in [`ROM_NATIVE_COMBAT_PASS_2026-10-03.md`](ROM_NATIVE_COMBAT_PASS_2026-10-03.md). The ordinary randomized companion pool is now restricted to the active five identities; the configured factory remains broader for explicit party construction. Final production build passed with asset audits, compression, and bundle verification. Exact-dist browser QA passed with 11 screenshots captured and four representative roster, dungeon, NPC-stat, and mobile screenshots visually reviewed; current chunks were `index-D_h9IRuA.js` and `combatStore-BiA9BOQG.js`. There were no page, server, or HTTP errors and the network response-errors list was empty; the only console error was the optional existing favicon 404. A release check passed on 249 paths; the final documentation update requires the final hygiene/release check. The remaining limits are not a readiness promotion or complete balance certification.
@@ -0,0 +1,44 @@
# Targeted native RoM healing sustain audit — 2026-10-04
## Scope
This diagnostic runs three consecutive Barren Caves Anglo pulls at levels 60 and 80. Each pull uses the original RuneWaker NPC 101462 attack list and the existing boss health budget. The active five are the native RoM Knight player in the tank seat and owned Priest, Rogue, Scout, and Mage NPCs. The Priest is measured as the owned party actor `party-healer-0-rom-priest`; its healing events are selected by that exact `sourceActorId`, independently of player healing.
The test preserves party HP, Mana, effects, and cooldowns between pulls. The first interval has no enemy for 10 seconds; the second has no enemy for 90 seconds. It does not reset the party between encounters. The focused test writes its measurements to `artifacts/rom-enemy-followup/native-sustain-2026-10-04.json` when `ROM_NATIVE_SUSTAIN_REPORT=1`.
Heal-event `rawAmount` is reported separately because it can be affected by source modifiers. Event publication occurs synchronously inside the heal state update, so the test reads pre-heal actor HP in the event callback and derives post-heal HP from `effectiveAmount`. Delivered healing is the conserved `effectiveAmount + overheal`; the waste share below uses that delivered amount. Cast-time Mana cost is an estimate reconstructed from the actor's ability-cost calculation at release. Starting, minimum, and ending Mana are actual NPC resource-pool readings. The capacity shown in the table is the NPC's resource maximum captured at pull start; temporary effects may change capacity during a pull.
## Results
All six pulls cleared with the player and all four NPCs alive. The Priest never ran out of Mana. Pull time and boss damage vary by level and pull:
| Level | Pull | Clear | Boss damage to party | Priest minimum Mana / starting capacity | Minimum / starting capacity | Estimated Priest Mana cost |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| 60 | 1 | 79.1 s | 3,233 | 6,138 / 6,904 | 88.9% | 979 |
| 60 | 2 | 90.4 s | 7,344 | 5,711 / 8,077 | 70.7% | 911 |
| 60 | 3 | 80.6 s | 5,588 | 7,521 / 8,077 | 93.1% | 746 |
| 80 | 1 | 124.1 s | 47,618 | 17,286 / 17,924 | 96.4% | 1,255 |
| 80 | 2 | 123.3 s | 27,704 | 17,683 / 19,736 | 89.6% | 1,265 |
| 80 | 3 | 118.7 s | 24,385 | 19,123 / 19,736 | 96.9% | 1,178 |
Across level 60, the NPC Priest delivered 110,080 healing: 39,753 effective and 70,327 overheal (63.9%). At level 80 it delivered 264,241: 183,396 effective and 80,845 overheal (30.6%). The raw event-amount sums were 110,080 and 264,241 in these samples. The Priest released 15 `Heal` and 12 `Regenerate` casts at level 60, and 19 `Heal` and 13 `Regenerate` casts at level 80. Regenerate tick overheal accounts for 32,718 at level 60 and 51,936 at level 80; direct Heal packets account for 37,609 and 28,909 respectively. Periodic ticks after a paid cast do not each spend Mana.
Mana at the second pull shows what a short break restores without resetting actors. At level 60, the Priest had 6,154 Mana after pull 1 and 6,403 after 10 seconds; at level 80 it had 17,327 and 18,305. The 90-second breaks restored 2,366 at level 60 and 2,053 at level 80, taking the Priest to full of its pull-3 starting capacity.
The measured Priest remained at full health through all four breaks: 7,935/7,935 at level 60 and 29,286/29,286 at level 80. Cooldowns continued to run instead of being reset. For example, `rom-priest-general-3` had 50.9 seconds remaining before the level-60 short break and 40.9 afterward; at level 80, a 113.2-second cooldown became 23.2 seconds over the long break.
## Healing decision observation
The owned NPC attribution showed substantial overheal but not a safe direct-heal suppression rule. All observed Priest `Heal` cast starts had a pending `rom-priest-general-2` Regenerate, with the target's party/player health snapshot between 58% and 90%; the mirrored combat-actor HP is recorded separately to expose state differences. The casts take two seconds while Anglo continues attacking, and the lowest observed target snapshot was in that pressure window. Merely detecting a HoT would therefore suppress some direct heals during active damage. No full-health direct Heal appeared in the party/player health snapshot at cast start, and at least one direct heal remained observable with an active HoT at 60% health or below. The focused test guards both observations.
No healing AI or source recovery change was made. The audit records meaningful level-60 overheal and tests the emergency path, but it does not establish that changing the native source rates, direct-heal threshold, or HoT timing would improve survival across encounters. This remains a targeted sustain diagnostic, not a balance or readiness certification.
## Validation limits
The result covers one selected source boss, one active-five seating, and six deterministic local pulls. It does not establish campaign traversal, all RoM healer pairings, different boss attack mixes, mobile scene behavior, or class readiness. Run only the focused test for this diagnostic:
```powershell
$env:NODE_OPTIONS='--max-old-space-size=8192'
$env:ROM_NATIVE_SUSTAIN_REPORT='1'
npx vitest run src/game/romNativeSustain.integration.test.ts
```
@@ -0,0 +1,195 @@
# RoM rotation add-on reference and clean-room profiles — 2026-10-03
## Scope and conclusion
This note records source research for the five exact Chronicles of Arcadia (CoA) class pairs used by the native five-character roster:
| Main / secondary class | Roster identity | Role |
| --- | --- | --- |
| Knight / Scout | Rorik | Tank |
| Priest / Knight | Serenna | Healer |
| Rogue / Warrior | Veyra | Damage |
| Scout / Rogue | Caelan | Damage |
| Mage / Warlock | Durnik | Damage |
The mapping is defined in [`romFiveIdentities.ts`](../src/game/romFiveIdentities.ts). The current CoA add-on source I could verify is **KittyCombo**, a user-configurable priority engine, rather than a set of published, maintained rotations for these five pairs. The only detailed DIYCE rotation material located for an exact target pair is a **2018 Scout/Rogue example**; it is historical and cannot be treated as a current CoA rotation. No source found verifies a complete, current, exact-pair rotation for any of the five builds. The priority sketches below are clean-room proposals informed by current CoA skill references and local runtime features; they are not copied add-on rotations.
## Add-on source provenance
### KittyCombo — current CoA add-on, engine rather than preset source
CurseForge identifies KittyCombo as a customizable combat engine for class combinations. Users create ordered actions and conditions, then can import or export rotation configurations. The project page names mdnss as author and devikaria as contributor; it lists GPLv3, version 2.4.1003 / file 8487473, uploaded 2026-07-22, with game version 11.0.0.0000. The listing and its source page do not publish a current rotation package or a verified skill priority for any target pair.
- [KittyCombo project page](https://www.curseforge.com/chronicles-of-arcadia/addons/kittycombo)
- [KittyCombo source page](https://www.curseforge.com/chronicles-of-arcadia/addons/kittycombo/source)
- [KittyCombo 2.4.1003 file record](https://www.curseforge.com/chronicles-of-arcadia/addons/kittycombo/files/8487473)
- [CoA help: add-on installation](https://chroniclesofarcadia.com/en/help/900178)
The source page advertises source availability, but did not expose a repository URL in the browser view. A read-only archive fetch was attempted for file 8487473 to the ignored `artifacts/rom-rotation-reference/` directory. The ordinary network sandbox blocked the first request; an authorized retry reached a Cloudflare JavaScript/cookie challenge instead of an archive, so no file was created. No add-on code was executed or installed. Public metadata and source-page references remain available above.
### DIYCE — historical priority-engine ideas, not current-pair guidance
The official CoA forum has a 2016 DIYCE post describing a skill-discovery/readiness loop and a 2018 DIYCE post describing an ordered, button-triggered priority evaluation. They illustrate durable ideas—evaluate learned skills, check cooldown/resource/use predicates, then choose the first eligible action—but the post code is historical and has no stated license. I did not copy or adapt its code.
- [Nadaná's DIYCE introduction (2016)](https://forum.chroniclesofarcadia.com/thread/640-diyce-ingame-macros-and-more/)
- [GhostWolf's DIYCE thread (2018)](https://forum.chroniclesofarcadia.com/thread/1649-do-it-yourself-combat-engine-diyce/)
The 2018 thread includes a Scout/Rogue-oriented example with resource thresholds, a silence condition, control, and damage priorities. That is an exact pair and orientation match for Caelan, but it is an eight-year-old reference; its priorities, spell names, timings, or API assumptions should not be carried into present-day CoA. It also shows a Warrior/Rogue branch, the reverse of Veyra's Rogue/Warrior, so it is not a pair match for Veyra.
CurseForge also retains [DIY Combat Engine v2](https://www.curseforge.com/rom/addons/diy-combat-engine-v2), an old RoM project (project 55223, updated 2013-04-18, game version 5.0.9) by `_ForgeUser10895305`. Its “Just give credit” terms do not make it a current CoA source, and its description says users supply/build class-combination scripts. I treated it as historical context only.
## Current CoA class-pair references
The official CoA skill pages below were used to confirm pair orientation and selected skill prerequisites or mechanics. Pair lists are live references and may change; in particular, the Priest/Knight pair page could not be retrieved in the browser session. That profile proposal therefore uses the current general Priest list, the current special-skills index, and current CoA patch notes, and requires runtime checks against the actual learned catalog before a skill can be selected.
- [Knight/Scout skills](https://chroniclesofarcadia.com/wiki/skills/list/6-2?locale=en): Holy Seal, Whirlwind Shield, ranged Arrow of Vengeance, shield-dependent abilities, mitigation, and control-break skills.
- [Priest/Knight skills](https://chroniclesofarcadia.com/en/wiki/skills/list/5-6) (page fetch unavailable in this research); [Priest common skills](https://chroniclesofarcadia.com/wiki/skills/list/5-12?locale=en) and the [special-skills index](https://chroniclesofarcadia.com/en/wiki/basic-information/special-skills) were accessible.
- [Rogue/Warrior skills](https://chroniclesofarcadia.com/en/wiki/skills/list/3-1): Rogue Energy plus Warrior Rage, weapon-dependent attacks, Rotten application, and bleed-sensitive damage.
- [Scout/Rogue skills](https://chroniclesofarcadia.com/wiki/skills/list/2-3?locale=en): ranged attacks and ammunition, Focus and Energy costs, poison effects, and Vampire Arrows-dependent attacks.
- [Mage/Warlock skills](https://chroniclesofarcadia.com/en/wiki/skills/list/4-9): shared mana/focus decisions, curse and damage-over-time mechanics, channels, and cooldown alignment.
- [Current special-skills index (revision 2026-08-31)](https://chroniclesofarcadia.com/en/wiki/basic-information/special-skills).
- [CoA patch 11.1.5 notes](https://chroniclesofarcadia.com/tr/patch/11-1-5-3000), which document current Priest/Knight changes: Calm Heart radius, Life Link becoming active, and Sacrificial Cure changing to MP cost with an MP-based shield.
One practical versioning warning: the local [`romPlayerCatalog.generated.json`](../src/game/romPlayerCatalog.generated.json) contains a RuneWaker-derived source snapshot with CoA overrides. It is useful as the project's runtime candidate catalog, but it is not itself proof that every base skill value matches the current CoA live pages. Rotations must select only skills present in the active ruleset catalog, then respect that catalog's learned-level, weapon, range, resource, cooldown, and target gates. In particular, historical Priest/Knight behavior must not be used to infer today's Sacrificial Cure cost.
## Clean-room selection contract
`combatRotation.ts` retains the WoW specialization profiles; `rotationProfileFor` selects on primary class and specialization and does not discriminate a secondary class or role. The companion [`romFiveRotation.ts`](../src/game/romFiveRotation.ts) resolves native RoM profiles separately by the exact tuple `(ruleset, primaryClassId, secondaryClassId, role)` and returns no profile when the tuple does not match. The five pairs above are ordered pairs: `rom-scout/rom-rogue` does not imply `rom-rogue/rom-scout`.
The existing rotation context already carries most useful decisions: learned abilities, readiness/cooldown, resource and maximum resource maps, health, hostile target/cast, enemy count, threat state, defensive-mechanic signals, party health/role/status, movement, maintained effects, and pets. Runtime availability (`learned`, `ready`, `canUse`, in-range, valid weapon, sufficient resource, legal target) must be a hard gate, not merely a score penalty. Every profile is an ordered set of predicates, with emergency overrides before ordinary damage priorities.
Use actor-owned, target-owned status state when tracking a DoT. Refresh a DoT only when it is absent or close to expiry; do not reapply it on every priority evaluation. Existing Death Knight disease tracking in [`partyRuntime.ts`](../src/game/partyRuntime.ts) is a local model for source/target ownership and tick timing. Buffs, marks, and setup effects should likewise refresh only when missing or expiring. A class-specific predicate may add weapon, ammunition, status, resource, enemy-count, movement, cast, or threat requirements, but it cannot bypass the shared runtime gates.
Generic pseudocode:
```text
profile = exactProfile(ruleset, primaryClass, secondaryClass, role)
if profile is absent: use no RoM rotation
for action in profile.emergencyOverrides followed by profile.priorityList:
if not learned(action) or not ready(action) or not canUse(action): continue
if weapon/ammo/status/resource/range/target gates fail: continue
if action applies a maintained effect and that effect is healthy: continue
if action is a damage-over-time effect and own effect expires later than refreshWindow:
continue
if action.predicate(context): cast action; stop this evaluation
```
`refreshWindow` should be derived from the active effect duration / tick schedule and evaluated using effect ownership in runtime state, not guessed from an old macro's timer.
## Proposed exact-pair priority sketches
These sketches define decision order and use conditions, not mandatory button rotations. Every action is conditional on being learned and unlocked at that character's level, existing in the active CoA ability catalog, and passing runtime use checks.
### Knight / Scout — Rorik, tank
```text
if a dangerous cast/mechanic is incoming and an applicable mitigation is ready:
use the narrowest effective group/personal mitigation
if control is active or imminent and the learned break is ready:
use control break
if an enemy is loose from the tank or the assigned target is outside threat control:
use a legal taunt (ranged Arrow of Vengeance only with a ranged weapon)
if multiple enemies are engaged and shield is equipped and threat needs building:
use Whirlwind Shield
if Holy Seal stacks are absent/low:
build Holy Seal with a legal builder
if a defensive hit is imminent or self-survival needs a shield:
spend Holy Seal through Truth Shield Bash when learned/usable
else if a control/interrupt use is required and Shock's seal cost is acceptable:
spend Holy Seal through Shock
else:
preserve useful seal stacks; use the best legal single-target threat/damage action
use Heavenly Arrows only when its channel is safe and multiple targets justify it
use non-urgent long cooldowns only for a dangerous encounter window
```
Holy Seal's current pair page lists a ten-second effect and stack generation; Truth Shield Bash consumes it to create a defensive shield, while Shock is a damage/control spender. Do not spend those stacks automatically when a dangerous mechanic is near. Whirlwind Shield is shield-gated and provides area threat. Arrow of Vengeance is ranged-weapon gated and should respond to real threat loss, not be a routine damage action. Sacred Protection and other long cooldowns belong to incoming group damage or a defensive threshold, not a fixed timer. These candidate mechanics are from the [current Knight/Scout page](https://chroniclesofarcadia.com/wiki/skills/list/6-2?locale=en); use current catalog data for unlocks and costs.
### Priest / Knight — Serenna, healer
```text
if an ally has an actionable harmful effect and a matching dispel is learned/ready:
dispel
if a living ally is at emergency health or a lethal mechanic is landing:
use the fastest safe direct heal / emergency shield
if several allies are hurt and group healing is efficient, affordable, and not exhausted:
use Group Heal
if one ally has a meaningful deficit and a safe cast window:
use Heal
if a target needs sustained healing and its own Regenerate is absent/near expiry:
apply Regenerate
if a major damage window is imminent and a learned mitigation/absorption can prevent it:
apply it before impact
if Calm Heart's current condition is met (fear/status or boss warning):
use Calm Heart
if an ally is dead and combat has ended:
resurrect
```
Keep urgent single-target healing ahead of cast-time healing when an ally may die before the cast completes. Do not heal a full target, repeatedly overwrite an active HoT, or use Group Heal into its Exhaustion penalty when only one party member needs help. Use party deficits and expected incoming damage to decide whether a group heal is worth its mana cost. Patch 11.1.5 makes Life Link an active skill and changes Sacrificial Cure to MP cost with an MP-based shield; the logic must inspect the live active skill metadata and should use Sacrificial Cure as an emergency action only when the current resource and shield conditions justify it. Do not apply the old HP-cost interpretation. Calm Heart is a conditional fear response, not routine healing. References: the [Priest common skill page](https://chroniclesofarcadia.com/wiki/skills/list/5-12?locale=en), [special-skills index](https://chroniclesofarcadia.com/en/wiki/basic-information/special-skills), and [11.1.5 notes](https://chroniclesofarcadia.com/tr/patch/11-1-5-3000). Exact current Priest/Knight pair-page values remain unverified because that page could not be fetched.
### Rogue / Warrior — Veyra, damage
```text
if target is durable enough for setup and a useful bleed/poison is absent or near expiry:
apply one legal setup effect, respecting weapon and resource requirements
if target has a bleed / Rotten condition and Wound Attack is learned, ready, affordable:
use Wound Attack
if axe is equipped and Rotten is absent/near expiry and Death's Touch is legal:
apply Death's Touch
else if Poison Stab is legal, affordable, and Rotten is absent/near expiry:
apply Poison Stab
if burst cooldown is ready and target will survive the burst window:
use Assassin's Rage / other learned burst action
use the best legal direct attack while setup effects tick
```
Rogue Energy and Warrior Rage are separate resources; verify the specific cost rather than assuming one shared pool. Rotten/bleed effects should be tracked by source and target and refreshed near their actual expiration, so repeated Poison Stab or Death's Touch does not clip remaining damage. Death's Touch is axe-dependent, and other attacks may also be weapon-gated. Wound Attack benefits from existing bleed/grievous conditions, so it should not outrank establishing those conditions. Save major burst for an encounter window that will last long enough to realize it. See the [current Rogue/Warrior page](https://chroniclesofarcadia.com/en/wiki/skills/list/3-1); do not import the historical reverse Warrior/Rogue branch from DIYCE.
### Scout / Rogue — Caelan, damage
```text
if the target is worth a control effect and control is needed to interrupt/prevent danger:
use the least disruptive learned control; avoid breaking it with casual damage
if target is durable and poison is missing/near expiry:
apply the legal poison setup (respect ammunition/status prerequisites)
if Vampire Arrows' required effect is missing and Deadly Poison Bite is a planned follow-up:
establish Vampire Arrows; then use Bite only when its own effect is absent/near expiry
if own poison effects are healthy:
do not refresh them early
if a multi-target pack is present and poison spread is useful:
use an ammunition-legal spread action
otherwise:
use the best legal ranged/rogue direct action without starving its required resource
```
This exact pair has both Focus and Energy decisions, ranged ammunition constraints, and setup-dependent poison attacks. Vampire Arrows is a prerequisite for Deadly Poison Bite in the current pair reference; the attack should not be attempted until that current status gate is satisfied. Combo Shot / Reflected Shot can be ammunition-gated and can contribute poison spread when the poison setup is present, so spreading is justified only when there are enough durable targets. Lasso or Blinding Powder should be reserved for a control need because subsequent damage can break control. Weak Spot is an affordable-enough-only decision, not an unconditional cast. These are the current [Scout/Rogue page](https://chroniclesofarcadia.com/wiki/skills/list/2-3?locale=en) mechanics; the old DIYCE branch is only a historical example.
### Mage / Warlock — Durnik, damage
```text
if a useful curse is missing/near expiry and no active channel will apply it:
apply the curse
if a useful Soul Pain effect is missing/near expiry and no active channel will apply it:
apply Soul Pain
if Cast Shadow is learned and enemies are in its area and channeling is safe:
channel Cast Shadow instead of duplicating its automatically applied effects
if Shadow Beam is learned, resources support the channel, and target uptime is good:
channel Shadow Beam; do not overwrite effects it reapplies
if Elemental Catalysis is ready and a good sustained/channel damage window exists:
align it with the channel/burst window
if Will of Light's stack threshold is met and mana recovery is useful:
convert stacks for mana / its current effect
use legal direct damage while preserving sufficient mana/focus to finish a valuable channel
```
Cast Shadow and Shadow Beam have channel-specific side effects, so the ordinary curse/DoT refresh branch must stand down when that channel will refresh them. Avoid moving or interrupting a channel unless an encounter mechanic requires it. Elemental Catalysis is a burst window; align it with useful channel uptime instead of spending it on an empty or soon-to-die target. Will of Light is a stack-gated recovery choice, not a routine priority. Soul Pain also interacts with Focus; preserve enough resources for useful follow-ups. Confirm the exact active unlock/cost metadata at runtime. References: the [current Mage/Warlock page](https://chroniclesofarcadia.com/en/wiki/skills/list/4-9).
## Fit with current HealerMan runtime and validation
[`combatRotation.ts`](../src/game/combatRotation.ts) keeps the shared evaluator and WoW profile registry. The exact RoM profiles and their native-only action guards are implemented in [`romFiveRotation.ts`](../src/game/romFiveRotation.ts); [`partyAbilityAi.ts`](../src/game/partyAbilityAi.ts) and [`benchmarkController.ts`](../src/game/benchmark/benchmarkController.ts) pass learned, cooldown/resource-ready actions and runtime target/effect state into that evaluator. The evaluator still applies `canUseAbility`, target, and movement gates. `partyRuntime.ts` owns actor/target periodic-effect bookkeeping; refresh decisions use the source actor, target, and remaining duration when those records are present.
The focused [`romFiveRotation.integration.test.ts`](../src/game/romFiveRotation.integration.test.ts) runs the five exact profiles against active learned catalog abilities and checks Serenna's Group Heal and urgent direct-heal ordering, owned/foreign and near-expiry DoTs, and the unchanged WoW profile selector. The existing [`romFiveNativeDungeonHealth.test.ts`](../src/game/romFiveNativeDungeonHealth.test.ts) remains the targeted player/companion diagnostic across its selected native dungeon and level cases. Keep follow-up runtime comparisons restricted to the five listed identities and existing diagnostic cases; this work does not authorize a broad RoM sweep or readiness promotion.
This file records source research; the resulting clean-room profile and native/shared-evaluator coverage live separately in [`romFiveRotation.ts`](../src/game/romFiveRotation.ts), [`romFiveRotation.test.ts`](../src/game/romFiveRotation.test.ts), and [`romFiveRotation.integration.test.ts`](../src/game/romFiveRotation.integration.test.ts). The focused unit and integration files currently pass together (17 tests).
+54
View File
@@ -0,0 +1,54 @@
# RoM Vow of Silence live Boss Testing evidence — 2026-10-04
## Corrected final-build replay
The corrected production build passed the dedicated live Boss Testing replay. A UI-bound RoM Priest/Knight used Vow of Silence on Lady Anacondra's actual source-scheduled Lightning Bolt. The Bolt ended before its scheduled completion, the boss held the 8.4-second silence status for its full duration, and no nonphysical active cast or cast start was observed while that status was active. Lady naturally resumed casting Nature Lightning Bolt 61 ms after the status expired. The final harness recorded 39 active-status samples.
The run used `playtest-artifacts/vow-live-final/final-build-20261004-153859692/results.json`. At script start it hashed all six requested final production assets:
| Asset | SHA-256 |
| --- | --- |
| `index-Cm7Olrd2.js` | `590a12db36de688cd0b8ffddc0a9c0a2102a93bbf9fc3c72d60f4a712d1e9c93` |
| `combatStore-1B4KOVhS.js` | `58296b0213524f7a3822dfdde78739c8bf918f15149abb85401a05bb1b8b3341` |
| `GameRuntime-hM1AaOYR.js` | `b307db151ff4a6601bfecb9aa8f2860cdcb1b5097798185c10b77300ad0f0f30` |
| `PartyFrames-BfZEkXH5.js` | `4d3a06c8906c5c3f6c4e9fc4131637cf21e8ee828eea7e4f651d8f6ffc07cf2d` |
| `romAbilityCatalog-DjRUCJbs.js` | `88fb48add6b2411011861ba4e6c4465e31be997b3d5d4a6e18a8a69fd116dce3` |
| `index-499eecpe.css` | `c8569a67e221f96ebf7c68c32b2c057854f702ffe497250b96273fde89bf12d9` |
The fight used the dedicated Boss Testing UI, a newly UI-created level-30 RoM Priest with RoM Knight as secondary class, and the four owned native RoM companions Rorik (Knight/Scout), Durnik (Mage/Warlock), Caelan (Scout/Rogue), and Veyra (Rogue/Warrior). Vow (DBC 490275) was bound through Ability controls to Shift+Digit5. Combat used ordinary keyboard commands and read-only snapshots; no enemy cast, target, position, cooldown, HP, or resource was staged or changed by the harness. The isolated character's level and secondary class were the only setup changes.
- Lady Anacondra was the live target `lady-anacondra-spawn`, source entry 3671. Her source mechanic immunities were `fear` and `horror`; she had no school immunities. Vow metadata was hostile target, range 0–150, with no radius or targeting shape.
- The observed Lightning Bolt was interruptible Nature `spell-9532`, started at 1791128371234 and scheduled to finish at 1791128372734. The player's Vow completed on Lady at 1791128371374. The immediate target snapshot had no active cast, and the completed player Vow event targeted Lady. The applied status `silence` expired at 1791128379774—exactly 8,400 ms after the Vow event.
- Player position was approximately `[-15.3401, -87.6799, 314.1800]`; Lady was `[-15.34, -87.70, 297.18]`, within Vow's 150-unit range. The full status window contained 39/39 active silence samples, zero nonphysical active casts, and zero nonphysical cast starts. Lightning Bolt's cast count stayed at 1 during silence.
- With the party on its ordinary Defend command during the observation interval, Lady's source-scheduled Nature Lightning Bolt resumed at 1791128379835, 61 ms after silence expired. The encounter then continued normally and Lady was defeated.
- The presentation callback did not expose a usable named subscription in this production bundle, so no `cast-cancel` presentation event is claimed. Cancellation is supported by the read-only state transition from the observed live Bolt to no active cast before its scheduled end, together with the successful player Vow event and the boss's applied silence status.
The Boss Testing encounter ended with Lady defeated, the player and all companions alive, 0 XP awarded, no loot created, and readiness still “Needs testing.” Final vitals were:
| Actor | Health | Resource |
| --- | --- | --- |
| Player | 2,163 / 2,163 | 2,901.9 / 3,030.9 mana |
| Rorik | 1,830.4 / 3,038.4 | 1,104.3 / 1,315.0 mana |
| Durnik | 1,891.0 / 1,891.0 | 1,866.1 / 1,866.1 mana |
| Caelan | 1,930.5 / 2,293.5 | 75.8 / 100 focus |
| Veyra | 2,384.3 / 2,384.3 | 36.0 / 100 energy |
I inspected the pre-Vow, post-Vow, and fight-end screenshots. The first shows Lady's active Lightning Bolt bar; the second shows the cast gone; the end screen shows Lady defeated with readiness unchanged.
- [Boss Testing Lady Anacondra selection](../playtest-artifacts/vow-live-final/final-build-20261004-153859692/boss-testing-lady-selection.png)
- [Vow UI binding](../playtest-artifacts/vow-live-final/final-build-20261004-153859692/vow-binding-ui.png)
- [Live Lightning Bolt before Vow](../playtest-artifacts/vow-live-final/final-build-20261004-153859692/lightning-bolt-pre-vow-1.png)
- [Live cast immediately after Vow](../playtest-artifacts/vow-live-final/final-build-20261004-153859692/lightning-bolt-post-vow-1.png)
- [Fight end](../playtest-artifacts/vow-live-final/final-build-20261004-153859692/fight-end.png)
## Historical pre-fix diagnostic
The earlier final-build snapshot in `playtest-artifacts/vow-live-final/final-build-20261004-151736260` showed that an active Nature Thorns cast could appear during the Vow silence window. Its timeline recorded 53 Thorns active-cast snapshots. Parent-side source review traced this to Thorns 8148 being Nature with self/aura behavior but melee delivery and no explicit attack kind or damage class, which let the previous selector bypass silence. That pre-fix evidence is retained here for comparison; the corrected replay above covers the rebuilt assets.
An even earlier assertion treated `schoolLockedUntil.nature === 0` as proof that Vow failed. That was a harness false negative: Vow's aura applies the silence status and cancels the cast before its separate interrupt effect can establish the school lock. The corrected replay checks the applied status and the active-cast state through the entire duration instead.
## Harness and validation
The dedicated, bounded browser harness is [scripts/playtest-vow-live-final.cjs](../scripts/playtest-vow-live-final.cjs). It snapshots and hashes the production assets, creates a disposable isolated profile/database and Edge session, and uses ordinary UI inputs during combat. It samples the actual silence status, detects nonphysical active casts while that status is live, checks Lightning Bolt cast counts, and records a naturally scheduled Nature-cast resumption after expiry when the boss remains alive. It does not modify combat-store state.
`node --check scripts/playtest-vow-live-final.cjs` and `npm run release:check` passed. The browser run completed with no page errors; the only console error was the local preview's missing optional favicon. No build, pretest, full test suite, loot action, or readiness action was run by this playtest subprocess. These results cover Boss Testing only and do not claim a Wailing Caverns campaign clear.
+27
View File
@@ -0,0 +1,27 @@
# Vow of Silence and Lady Anacondra — 2026-10-04
## Finding
The full-fight record shows a successful Vow of Silence action against Lady Anacondra at `1791081712932`, but the matching cast remained active. The cast identity is `attackId: spell-9532`, `startedAt: 1791081712018`, `completesAt: 1791081713518`; the action occurred 914 ms after its start with 586 ms remaining. The result records `interruptObserved: false` and `sourceCastEndedBeforeScheduledCompletion: false`. The source cast after the action still has the same ID and timestamps. Vow resolves as an Arcane, zero-amount utility event, so that event is not evidence of a damage resistance or miss.
The failure was a false source immunity. The pinned AzerothCore `creature_template.sql` schema stores `CreatureImmunitiesId`, a row reference, rather than a literal mechanic bitmask. The importer had treated that negative row ID as a mask. At runtime, `applyStatus` rejects Silence and Interrupt when those names appear in `mob.mechanicImmunities`, which prevented both Vow effects from reaching the active cast.
## Source correction
The import source is AzerothCore commit `4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3`, recorded by `azerothCoreSourceLock.json`. The additional source is `data/sql/base/db_world/creature_immunities.sql`, SHA-256 `aee81a1d8b99986f8498a2b4c51b64eaf4b9095a0d1a12bbf3705d9588a90a13` (70,706 bytes, 456 rows), preserved byte-for-byte under `dungeon-pipeline/source-fixtures/azerothcore-4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3/` with its lock metadata.
Lady Anacondra (template 3671) references immunity row `-93`. That row has `SchoolMask=0` and `MechanicsMask=16777248` (`0x01000020`); its source comment identifies Fear and Horror as `mech=0x800010`. The source mechanic mask uses the enum's bit positions, while the established imported/runtime template mask uses the adjacent lower positions. The importer normalizes the source mechanic mask one bit right to `0x00800010`, then decodes exactly `fear` and `horror`. It imports the row's SchoolMask separately. This removes the invented Silence and Interrupt immunities without weakening any immunity declared by source. For example, row `-377` explicitly lists both Silence and Interrupt and still imports both.
The importer only loads the FK table when the parsed template schema declares `CreatureImmunitiesId`; older schemas continue to use literal `mechanic_immune_mask` and `spell_school_immune_mask` values. A nonzero FK missing from the source table fails closed. The generated reference changes only immunity fields: 646 of 1,671 creature templates have corrected mechanic immunity fields, and 30 also receive their FK-backed school immunity fields. Other template fields and counts are unchanged.
## Runtime check and limits
Vow of Silence is modeled as an 8.4-second Silence aura followed by an Interrupt effect; the player-target aura duration is 4.2 seconds. Applying Silence cancels a live nonphysical cast. Because that clears the cast before the following Interrupt effect runs, the effect sequence does not create a separate per-school lockout for the canceled Nature cast. The active Silence status itself suppresses new spell attacks during its duration. The regression therefore checks cast cancellation and the live Silence window, not a Nature school-lock timestamp.
The focused regression registers Lady through template 3671, stages the exact `spell-9532` cast with its source start/completion times, verifies cancellation, and advances through the original release time while checking the cast count does not increase. Other focused cases cover a RoM physical Trample against a player target, the generated companion path against a WoW Lightning Bolt, and an NPC whose source row really grants Silence and Interrupt immunity. That immune NPC keeps its cast and receives no Silence.
This is a source/import correction and focused runtime verification. The recorded historical full fight was not replayed after the change, and this report does not claim a new browser fight result.
## Validation
The focused Vitest selection passed 40/40 tests across `src/game/romPriestChampionRuntime.test.ts`, `src/game/wailingCavernsParity.test.ts`, `src/game/enemyCasting.test.ts`, `src/game/enemyDamageTypes.integration.test.ts`, and `src/game/enemySmartAiRuntime.test.ts`; the final revised Vow test file was rerun alone and passed 8/8. The importer regression file `scripts/dungeon-pipeline/import-azerothcore-world.test.mjs` passed 5/5 Node tests, and `node --check scripts/dungeon-pipeline/import-azerothcore-world.mjs` passed. The app TypeScript check and final build passed after the runtime-test edits. No full test suite or all-class sweep was run.
@@ -0,0 +1,25 @@
# WoW Killing Spree audit — 2026-10-04
## Finding
The `combatRotation.test.ts` failure was a stale generic effect-shape expectation, not a missing player strike handler. Its representative-signature assertion required every class's chosen action to expose a non-utility effect. Killing Spree's local 51690 row is a periodic-dummy aura and target selector; the catalog therefore exposes a `class-script` utility marker for the dedicated Rogue path instead of fabricating a generic direct-damage effect. The catalog unit test already checks this marker and its 2,000-ms duration. The representative test now requires that exact shape for Killing Spree while retaining the direct-effect requirement for every other signature.
The audit also found a separate source-backed runtime defect: the player class script created a +1% damage aura, although Killing Spree's linked source child provides +20% damage. The runtime now applies +20% for the 2-second duration, and the Rogue mechanic regression checks the aura together with the existing ten strike events.
## Source evidence
HealerMan's generated local ability data identifies Killing Spree as talent node `talent-2076`. Rank spell 51690 has a 500-ms periodic aura, a 10-yard hostile target-selection effect, a 2-second duration, and a description whose damage token references child 61851.
The read-only local 3.3.5 Spell.dbc at `F:/Projects/wow-pvt-server-backups-2/build/bin/RelWithDebInfo/data/dbc/Spell.dbc` resolves child 61851 Effect 2 to AuraType 79 (`SPELL_AURA_MOD_DAMAGE_PERCENT_DONE`), 20 base points, and the all-schools mask. The local `spell_linked_spell.sql` links 51690 to 61851; `spell_script_names.sql` binds 51690 to `spell_rog_killing_spree`. The historical AzerothCore-derived [`spell_rogue.cpp`](F:/Projects/wow-pvt-server-backups-2/azerothcore-wotlk/src/server/scripts/Spells/spell_rogue.cpp) applies that buff and, on each periodic tick, chooses a valid target, teleports, and triggers the weapon-damage child. This server source corroborates the effect linkage and scripted shape; it is not claimed as pristine stock-core behavior.
## Player and companion boundary
The player `combatStore` path has a dedicated Killing Spree class-script branch. It emits five two-hand assaults and moves the player behind the last selected target. `wow335RogueMechanics.test.ts` verifies that both weapons produce ten damage events against a reachable target; it now also verifies the 20% aura modifier and duration.
`partyRuntime.executePartyAbility` has no Killing Spree branch. Its companion effect resolver handles other named scripts, but does not turn this utility marker into weapon strikes. Companion Killing Spree therefore remains unsupported; this pass makes no player/NPC parity claim and does not extend the companion runtime. The Combat Rogue policy naming Killing Spree does not establish that a party actor can resolve its scripted attacks.
The player implementation is still a simulator approximation. It resolves the five assaults immediately on cast rather than at the source's 500-ms periodic cadence, selects candidate targets deterministically rather than using the source script's random choice, and applies a local weapon formula instead of executing the complete 57841/57842 child chain. Source teleport and cast-interruption timing are not modeled per assault. This audit fixes the stale catalog-shape expectation and the independently verified buff amount; it does not certify complete Killing Spree timing, target-selection, interruption, or damage parity.
## Validation
`npx vitest run src/game/combatRotation.test.ts src/game/wow335AbilityCatalog.test.ts src/game/wow335RogueMechanics.test.ts` passed 81 tests across three files. The rotation test retains exact talent-node gating, rank/resource/cooldown checks, and a mechanism-specific class-script assertion for Killing Spree. The existing Rogue runtime test still requires all ten strikes; its added check asserts the source-backed +20% aura for 2 seconds. No broad class sweep, balance run, readiness change, loot wiring, cap change, or NPC behavior change was made.
@@ -0,0 +1,32 @@
# WoW Killing Spree player and companion parity — 2026-10-04
## Implemented behavior
Player and generated Rogue companions now share the timed Killing Spree window in `src/game/wow335RogueKillingSpreeRuntime.ts` and the owner-keyed scheduler in `src/game/combatStore.ts`. The local 51690 data gives the aura a 500-ms interval, a 2,000-ms duration, and `SPELL_ATTR5_EXTRA_INITIAL_PERIOD`; the modeled assaults therefore occur at cast time and at +500, +1,000, +1,500, and +2,000 ms. Each owner's window records its own target pool, weapon snapshots, and cooldown-backed ability identity. Irregular simulation advances process each due assault at its scheduled timestamp.
Both paths capture targets once when the cast starts. The player captures living, positioned targets in the local 10-yard area; party Rogue selection captures engaged targets in that range with line of sight. Later arrivals do not join the window. At each assault, dead, missing, stealthed, or invisible targets are pruned, then the simulator selects from the remaining list with replacement. Selection is deterministic and seeded for repeatable local combat; the historical script uses random selection.
The linked 61851 aura applies +20% damage for the two-second window. The simulator retains it through the scheduled final assault at exactly +2,000 ms, then removes it before the advance returns. An internal +1-ms expiration allowance preserves that event ordering without leaving the damage bonus available to subsequent actions at the boundary. Player pulses update `GameStore` relocation; companion pulses queue a one-shot combat relocation that `PartyPopulation` applies to the actor and snaps to a nearby navigation surface when one is available.
The companion AI can select its learned Combat Rogue action through the narrow rotation-semantics exception for local WoW progression spell 51690. Cooldowns remain attached to the casting actor. Windows are canceled with their owner's damage aura when the player dies or resets, or when a companion dies or leaves the party roster. A missing or invalid target is pruned independently and does not cancel the other actor's window.
## Evidence and authority
The static spell data inspected is the read-only snapshot at `../wow-pvt-server-backups-2/build/bin/RelWithDebInfo/data/dbc/Spell.dbc`; HealerMan's generated local catalog identifies 51690 as the Combat Rogue talent at `talent-2076`. In that DBC, 51690 has the 500-ms periodic dummy effect, 2-second duration, 10-yard hostile selection, and extra initial period. Linked spell 61851 is AuraType 79 (`SPELL_AURA_MOD_DAMAGE_PERCENT_DONE`) with 20 base points and all schools. The backup SQL row `../wow-pvt-server-backups-2/azerothcore-wotlk/data/sql/base/db_world/spell_linked_spell.sql` links 51690 to 61851; `spell_script_names.sql` binds 51690 to `spell_rog_killing_spree`.
The corresponding backup script is `../wow-pvt-server-backups-2/azerothcore-wotlk/src/server/scripts/Spells/spell_rogue.cpp` (Killing Spree aura and spell scripts around lines 304–420). It adds the initial spell-area targets to the aura script, applies the linked damage buff, and on each periodic callback chooses a retained target at random, removes invalid/stealth/invisible targets, casts teleport spell 57840, and triggers weapon damage spell 57841. Its target list is not expanded as later mobs arrive. `FilterTargets` rejects an empty list, vehicle use, and rooted casters, and removes existing auras marked for cast or spell-attack interruption before the spell proceeds. `HandleRemove` removes linked 61851.
This source is a local AzerothCore-derived server snapshot and includes fork-specific comments/behavior; it is evidence for that snapshot, not a claim about pristine stock AzerothCore or Blizzard server code. Extending the script's `ToPlayer()` teleport assumption to generated NPCs is HealerMan simulator behavior.
## Known simulator approximations and gaps
- The local simulator snapshots one hit per equipped hand per pulse and uses its existing weapon/AP estimate. It records hand-specific child identifiers 57841/57842, but it does not reproduce every triggered-child damage, proc, and combat-roll detail from the server's spell chain. Exact damage parity is not claimed.
- Target choice is stable seeded selection instead of the script's random choice. Party target capture also restricts the pool to engaged hostiles, matching HealerMan's companion encounter policy rather than asserting server-equivalent NPC targeting.
- The server invokes spell 57840 for teleport. The simulator approximates the result as 1.5 yards behind target orientation (or caster-facing fallback); companion landings additionally use HealerMan navigation-surface snapping. Exact teleport coordinates and collision behavior are not claimed.
- The source script's explicit interruption handling removes pre-existing auras with cast/spell-attack interrupt flags before the cast. The available 61851 data and script do not establish a separate stun or movement cancellation rule for the two-second periodic aura, so this simulator does not invent one. The precise semantics of control effects during the active window remain unverified.
The earlier [Killing Spree audit](WOW_KILLING_SPREE_AUDIT_2026-10-04.md) documents the prior immediate-player-strike gap; this parity note supersedes that section with the current shared timed implementation while retaining its original audit history.
## Validation
`npx vitest run src/game/wow335RogueMechanics.test.ts src/game/partyRuntime.test.ts src/game/partyRuntimeRegistry.test.ts` passed 71 tests. Coverage includes cast-time versus later target arrival, immediate and irregularly caught-up ticks, the boosted final pulse before aura removal, player relocation, companion-owned targets/cooldowns/relocation, and player death/reset plus companion removal cleanup. Ready-five and the combined application build/release checks are left to the parent task's final integration pass.
@@ -0,0 +1,10 @@
{
"schemaVersion": 1,
"repository": "https://github.com/azerothcore/azerothcore-wotlk",
"commit": "4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3",
"path": "data/sql/base/db_world/creature_immunities.sql",
"url": "https://raw.githubusercontent.com/azerothcore/azerothcore-wotlk/4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3/data/sql/base/db_world/creature_immunities.sql",
"file": "creature_immunities.sql",
"byteLength": 70706,
"sha256": "aee81a1d8b99986f8498a2b4c51b64eaf4b9095a0d1a12bbf3705d9588a90a13"
}
@@ -0,0 +1,511 @@
-- MySQL dump 10.13 Distrib 8.4.3, for Win64 (x86_64)
--
-- Host: localhost Database: acore_world
-- ------------------------------------------------------
-- Server version 8.4.3
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8mb4 */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `creature_immunities`
--
DROP TABLE IF EXISTS `creature_immunities`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `creature_immunities` (
`ID` int NOT NULL,
`SchoolMask` tinyint NOT NULL DEFAULT '0',
`DispelTypeMask` smallint NOT NULL DEFAULT '0',
`MechanicsMask` bigint NOT NULL DEFAULT '0',
`Effects` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`Auras` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
`ImmuneAoE` tinyint(1) NOT NULL DEFAULT '0',
`ImmuneChain` tinyint(1) NOT NULL DEFAULT '0',
`Comment` mediumtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
PRIMARY KEY (`ID`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `creature_immunities`
--
LOCK TABLES `creature_immunities` WRITE;
/*!40000 ALTER TABLE `creature_immunities` DISABLE KEYS */;
INSERT INTO `creature_immunities` VALUES
(-426,0,0,4294967038,'','',0,0,'mech=0x7FFFFF7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BLEED|BANDAGE|POLYMORPH|BANISH|SHIELD|SHACKLE|MOUNT|INFECTED|TURN|HORROR|INVULNERABILITY|INTERRUPT|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED|ENRAGED)'),
(-425,0,0,4294934270,'','',0,0,'mech=0x7FFFBF7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANDAGE|POLYMORPH|BANISH|SHIELD|SHACKLE|MOUNT|INFECTED|TURN|HORROR|INVULNERABILITY|INTERRUPT|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED|ENRAGED)'),
(-424,0,0,3386277110,'','',0,0,'mech=0x64EB3E7B(CHARM|DISORIENTED|DISTRACT|FEAR|GRIP|ROOT|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|INFECTED|TURN|HORROR|DAZE|SAPPED|ENRAGED)'),
(-423,0,0,3116367612,'','',0,0,'mech=0x5CDFFF7E(DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BLEED|BANDAGE|POLYMORPH|BANISH|SHIELD|SHACKLE|MOUNT|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|ENRAGED)'),
(-422,0,0,2147483646,'','',0,0,'mech=0x3FFFFFFF(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SLOW_ATTACK|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BLEED|BANDAGE|POLYMORPH|BANISH|SHIELD|SHACKLE|MOUNT|INFECTED|TURN|HORROR|INVULNERABILITY|INTERRUPT|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-421,0,0,2143289022,'','',0,0,'mech=0x3FDFFF5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BLEED|BANDAGE|POLYMORPH|BANISH|SHIELD|SHACKLE|MOUNT|TURN|HORROR|INVULNERABILITY|INTERRUPT|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-420,0,0,2143256254,'','',0,0,'mech=0x3FDFBF5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANDAGE|POLYMORPH|BANISH|SHIELD|SHACKLE|MOUNT|TURN|HORROR|INVULNERABILITY|INTERRUPT|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-419,0,0,2107014838,'','',0,0,'mech=0x3ECB3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-418,0,0,2076147382,'','',0,0,'mech=0x3DDFBF5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANDAGE|POLYMORPH|BANISH|SHIELD|SHACKLE|MOUNT|TURN|HORROR|INVULNERABILITY|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-417,0,0,2044099622,'','',0,0,'mech=0x3CEB3E13(CHARM|DISORIENTED|FEAR|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|INFECTED|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-416,0,0,2042592438,'','',0,0,'mech=0x3CDFBE5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANDAGE|POLYMORPH|BANISH|SHIELD|SHACKLE|MOUNT|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-415,0,0,2042592436,'','',0,0,'mech=0x3CDFBE5A(DISORIENTED|DISTRACT|FEAR|ROOT|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANDAGE|POLYMORPH|BANISH|SHIELD|SHACKLE|MOUNT|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-414,0,0,2042588342,'','',0,0,'mech=0x3CDFB65B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SLEEP|SNARE|FREEZE|KNOCKOUT|BANDAGE|POLYMORPH|BANISH|SHIELD|SHACKLE|MOUNT|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-413,0,0,2042588340,'','',0,0,'mech=0x3CDFB65A(DISORIENTED|DISTRACT|FEAR|ROOT|SLEEP|SNARE|FREEZE|KNOCKOUT|BANDAGE|POLYMORPH|BANISH|SHIELD|SHACKLE|MOUNT|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-412,0,0,2041872062,'','',0,0,'mech=0x3CDA3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|SHACKLE|MOUNT|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-411,0,0,2039906046,'','',0,0,'mech=0x3CCB3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-410,0,0,2039906038,'98,124,144,145','',0,0,'mech=0x3CCB3F7B(CHARM|DISORIENTED|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-409,4,0,2039905974,'','',0,0,'school=0x4(FIRE), mech=0x3CCB3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-408,0,0,2039905974,'114','11',0,0,'mech=0x3CCB3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-407,0,0,2039905974,'98,124,144,145','',0,0,'mech=0x3CCB3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-406,0,0,2039905974,'','',0,0,'mech=0x3CCB3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-405,0,0,2039905956,'','',0,0,'mech=0x3CCB3F52(DISORIENTED|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-404,0,0,2039905454,'','',0,0,'mech=0x3CCB3E57(CHARM|DISORIENTED|DISARM|FEAR|ROOT|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-403,0,0,2039905446,'','',0,0,'mech=0x3CCB3E53(CHARM|DISORIENTED|FEAR|ROOT|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-402,0,0,2039905444,'','',0,0,'mech=0x3CCB3E52(DISORIENTED|FEAR|ROOT|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-401,0,0,2039905318,'','',0,0,'mech=0x3CCB3E13(CHARM|DISORIENTED|FEAR|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-400,0,0,2039901750,'','',0,0,'mech=0x3CCB371B(CHARM|DISORIENTED|DISTRACT|FEAR|SILENCE|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-399,0,0,2039890982,'','',0,0,'mech=0x3CCB2213(CHARM|DISORIENTED|FEAR|SLEEP|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-398,0,0,2039890978,'','',0,0,'mech=0x3CCB2211(CHARM|FEAR|SLEEP|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-397,0,0,2038842402,'','',0,0,'mech=0x3CC32211(CHARM|FEAR|SLEEP|KNOCKOUT|POLYMORPH|BANISH|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-396,0,0,2038842400,'','',0,0,'mech=0x3CC32210(FEAR|SLEEP|KNOCKOUT|POLYMORPH|BANISH|TURN|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-395,0,0,2031517372,'','',0,0,'mech=0x3C8B3F5E(DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-394,0,0,2031516836,'','',0,0,'mech=0x3C8B3E52(DISORIENTED|FEAR|ROOT|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-393,0,0,2031516708,'','',0,0,'mech=0x3C8B3E12(DISORIENTED|FEAR|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-392,0,0,2031516704,'','',0,0,'mech=0x3C8B3E10(FEAR|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-391,0,0,2031512736,'','',0,0,'mech=0x3C8B3650(FEAR|ROOT|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-390,0,0,2031502372,'','',0,0,'mech=0x3C8B2212(DISORIENTED|FEAR|SLEEP|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-389,0,0,2031502368,'','',0,0,'mech=0x3C8B2210(FEAR|SLEEP|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-388,0,0,2031386302,'','',0,0,'mech=0x3C8A3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|SHACKLE|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-387,0,0,2030453792,'','',0,0,'mech=0x3C832210(FEAR|SLEEP|KNOCKOUT|POLYMORPH|BANISH|HORROR|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-386,0,0,2022966272,'','',0,0,'mech=0x3C4A0200(SLEEP|BANISH|SHACKLE|TURN|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-385,0,0,2013660192,'','',0,0,'mech=0x3C030210(FEAR|SLEEP|POLYMORPH|BANISH|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-384,0,0,2013528100,'','',0,0,'mech=0x3C020012(DISORIENTED|FEAR|BANISH|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-383,0,0,2013528064,'','',0,0,'mech=0x3C020000(BANISH|DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-382,0,0,2013265920,'','',0,0,'mech=0x3C000000(DAZE|DISCOVERY|IMMUNE_SHIELD|SAPPED)'),
(-381,0,0,1804534846,'','',0,0,'mech=0x35C7821F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|SLEEP|BANDAGE|POLYMORPH|BANISH|SHIELD|TURN|HORROR|INVULNERABILITY|DAZE|IMMUNE_SHIELD|SAPPED)'),
(-380,0,0,1770946302,'114','11',0,0,'mech=0x34C73F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|TURN|HORROR|DAZE|IMMUNE_SHIELD|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-379,0,0,1770663678,'','',0,0,'mech=0x34C5177F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|FREEZE|POLYMORPH|SHIELD|TURN|HORROR|DAZE|IMMUNE_SHIELD|SAPPED)'),
(-378,0,0,1539276542,'','',0,0,'mech=0x2DDFBF7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANDAGE|POLYMORPH|BANISH|SHIELD|SHACKLE|MOUNT|TURN|HORROR|INVULNERABILITY|DAZE|DISCOVERY|SAPPED)'),
(-377,0,0,1335787254,'','',0,0,'mech=0x27CF3F7B(CHARM|DISORIENTED|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|SHACKLE|TURN|HORROR|INVULNERABILITY|INTERRUPT|DAZE|SAPPED)'),
(-376,0,0,1305378558,'','',0,0,'mech=0x26E73F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|INFECTED|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-375,0,0,1304428286,'','',0,0,'mech=0x26DFFF7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BLEED|BANDAGE|POLYMORPH|BANISH|SHIELD|SHACKLE|MOUNT|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-374,0,0,1304395518,'','',0,0,'mech=0x26DFBF7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANDAGE|POLYMORPH|BANISH|SHIELD|SHACKLE|MOUNT|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-373,0,0,1303871230,'','',0,0,'mech=0x26DBBF7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANDAGE|POLYMORPH|BANISH|SHACKLE|MOUNT|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-372,0,0,1302232830,'98,124,144,145','',0,0,'mech=0x26CF3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-371,0,0,1302232830,'','',0,0,'mech=0x26CF3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-370,0,0,1302232766,'','',1,0,'mech=0x26CF3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=AVOID_AOE'),
(-369,0,0,1301741310,'98,124,144,145','',0,0,'mech=0x26CB7F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BLEED|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-368,127,0,1301741246,'','',0,0,'school=0x7F(NORMAL|HOLY|FIRE|NATURE|FROST|SHADOW|ARCANE), mech=0x26CB7F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BLEED|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-367,0,0,1301741246,'','',0,0,'mech=0x26CB7F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BLEED|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-366,16,0,1301708542,'98,124,144,145','',0,0,'school=0x10(FROST), mech=0x26CB3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-365,16,0,1301708542,'','',0,0,'school=0x10(FROST), mech=0x26CB3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-364,4,0,1301708542,'98,124,144,145','',0,0,'school=0x4(FIRE), mech=0x26CB3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-363,0,0,1301708542,'98,124,144,145','',1,0,'mech=0x26CB3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=AVOID_AOE|IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-362,0,0,1301708542,'98,114,124,144,145','11',0,0,'mech=0x26CB3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=NO_TAUNT|IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),114(ATTACK_ME),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST), auras=11(BIND)'),
(-361,0,0,1301708542,'98,124,144,145','',0,0,'mech=0x26CB3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-360,0,0,1301708542,'','',0,0,'mech=0x26CB3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-359,0,0,1301708540,'98,124,144,145','',0,0,'mech=0x26CB3F7E(DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-358,16,0,1301708538,'','',0,0,'school=0x10(FROST), mech=0x26CB3F7D(CHARM|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-357,0,0,1301708538,'98,124,144,145','',0,0,'mech=0x26CB3F7D(CHARM|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-356,0,0,1301708534,'98,114,124,144,145','11',0,0,'mech=0x26CB3F7B(CHARM|DISORIENTED|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=NO_TAUNT|IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),114(ATTACK_ME),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST), auras=11(BIND)'),
(-355,0,0,1301708534,'98,124,144,145','',0,0,'mech=0x26CB3F7B(CHARM|DISORIENTED|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-354,0,0,1301708534,'','',0,0,'mech=0x26CB3F7B(CHARM|DISORIENTED|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-353,0,0,1301708526,'98,124,144,145','',0,0,'mech=0x26CB3F77(CHARM|DISORIENTED|DISARM|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-352,4,0,1301708478,'114','11',0,0,'school=0x4(FIRE), mech=0x26CB3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-351,1,0,1301708478,'114','11',0,0,'school=0x1(NORMAL), mech=0x26CB3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-350,0,0,1301708478,'98,124,144,145','',0,0,'mech=0x26CB3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-349,0,0,1301708478,'114','11',0,0,'mech=0x26CB3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-348,0,0,1301708478,'','',0,0,'mech=0x26CB3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-347,0,0,1301708470,'114','11',0,0,'mech=0x26CB3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-346,0,0,1301708470,'','',0,0,'mech=0x26CB3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-345,0,0,1301708462,'','',0,0,'mech=0x26CB3F57(CHARM|DISORIENTED|DISARM|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-344,0,0,1301708022,'','',0,0,'mech=0x26CB3E7B(CHARM|DISORIENTED|DISTRACT|FEAR|GRIP|ROOT|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-343,0,0,1301707958,'','',0,0,'mech=0x26CB3E5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-342,0,0,1301706494,'98,114,124,144,145','11',0,0,'mech=0x26CB3B7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=NO_TAUNT|IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),114(ATTACK_ME),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST), auras=11(BIND)'),
(-341,4,0,1301704446,'','',0,0,'school=0x4(FIRE), mech=0x26CB377F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-340,0,0,1301704446,'98,124,144,145','',0,0,'mech=0x26CB377F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-339,0,0,1301704382,'98,124,144,145','',0,0,'mech=0x26CB375F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-338,0,0,1301703934,'','',0,0,'mech=0x26CB367F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-337,0,0,1301702270,'','',0,0,'mech=0x26CB333F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|SILENCE|SLEEP|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-336,0,0,1301677630,'98,124,144,145','',0,0,'mech=0x26CB031F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|SILENCE|SLEEP|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-335,0,0,1301677626,'98,124,144,145','',0,0,'mech=0x26CB031D(CHARM|DISARM|DISTRACT|FEAR|SILENCE|SLEEP|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-334,0,0,1301446398,'114','11',0,0,'mech=0x26C93F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-333,0,0,1301184254,'','',0,0,'mech=0x26C73F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-332,0,0,1301053166,'114','11',0,0,'mech=0x26C63F77(CHARM|DISORIENTED|DISARM|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|SHIELD|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-331,0,0,1300692670,'','',0,0,'mech=0x26C37F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BLEED|POLYMORPH|BANISH|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-330,0,0,1300692542,'114','11',1,0,'mech=0x26C37F1F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BLEED|POLYMORPH|BANISH|TURN|HORROR|INTERRUPT|DAZE|SAPPED), flags=NO_TAUNT|AVOID_AOE, effects=114(ATTACK_ME), auras=11(BIND)'),
(-329,8,0,1300397758,'','',0,0,'school=0x8(NATURE), mech=0x26C13F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|TURN|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-328,0,0,1293876926,'','',0,0,'mech=0x268F7F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BLEED|POLYMORPH|BANISH|SHIELD|SHACKLE|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-327,0,0,1293319926,'98,124,144,145','',0,0,'mech=0x268B3F7B(CHARM|DISORIENTED|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|INTERRUPT|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-326,8,0,1293319870,'','',0,0,'school=0x8(NATURE), mech=0x268B3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-325,0,0,1293319870,'','',0,0,'mech=0x268B3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-324,0,0,1293319862,'','',0,0,'mech=0x268B3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-323,0,0,1293318838,'','',0,0,'mech=0x268B3D5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-322,0,0,1293317684,'','',0,0,'mech=0x268B3B1A(DISORIENTED|DISTRACT|FEAR|SILENCE|SLEEP|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-321,0,0,1293315774,'','',0,0,'mech=0x268B375F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-320,0,0,1293315754,'','',0,0,'mech=0x268B3755(CHARM|DISARM|FEAR|ROOT|SILENCE|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-319,0,0,1293311670,'','',0,0,'mech=0x268B2F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-318,16,0,1293188798,'','',0,0,'school=0x10(FROST), mech=0x268A3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|SHACKLE|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-317,8,0,1293188798,'','',0,0,'school=0x8(NATURE), mech=0x268A3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|SHACKLE|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-316,4,0,1293188798,'','',0,0,'school=0x4(FIRE), mech=0x268A3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|SHACKLE|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-315,0,0,1293188798,'','',0,0,'mech=0x268A3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|SHACKLE|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-314,8,0,1292271358,'','',0,0,'school=0x8(NATURE), mech=0x26833F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-313,0,0,1292271358,'','',0,0,'mech=0x26833F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-312,4,0,1292271294,'','',0,0,'school=0x4(FIRE), mech=0x26833F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-311,0,0,1292271294,'','',0,0,'mech=0x26833F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-310,126,0,1292271286,'','',0,0,'school=0x7E(HOLY|FIRE|NATURE|FROST|SHADOW|ARCANE), mech=0x26833F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-309,0,0,1292271166,'','',0,0,'mech=0x26833F1F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-308,0,0,1292270270,'','',0,0,'mech=0x26833D5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-307,0,0,1292033726,'','',0,0,'mech=0x26816F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|KNOCKOUT|BLEED|POLYMORPH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-306,0,0,1292033724,'','',0,0,'mech=0x26816F5E(DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|KNOCKOUT|BLEED|POLYMORPH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-305,0,0,1292009150,'','',0,0,'mech=0x26813F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-304,8,0,1292000958,'','',0,0,'school=0x8(NATURE), mech=0x26812F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|KNOCKOUT|POLYMORPH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-303,0,0,1292000958,'','',0,0,'mech=0x26812F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|KNOCKOUT|POLYMORPH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-302,16,0,1292000954,'','',0,0,'school=0x10(FROST), mech=0x26812F5D(CHARM|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|KNOCKOUT|POLYMORPH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-301,8,0,1292000954,'','',0,0,'school=0x8(NATURE), mech=0x26812F5D(CHARM|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|KNOCKOUT|POLYMORPH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-300,4,0,1292000954,'','',0,0,'school=0x4(FIRE), mech=0x26812F5D(CHARM|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|KNOCKOUT|POLYMORPH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-299,0,0,1292000954,'','',0,0,'mech=0x26812F5D(CHARM|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|KNOCKOUT|POLYMORPH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-298,8,0,1291998778,'','',0,0,'school=0x8(NATURE), mech=0x26812B1D(CHARM|DISARM|DISTRACT|FEAR|SILENCE|SLEEP|STUN|KNOCKOUT|POLYMORPH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-297,0,0,1291996862,'','',0,0,'mech=0x2681275F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|KNOCKOUT|POLYMORPH|HORROR|INTERRUPT|DAZE|SAPPED)'),
(-296,0,0,1275494326,'','',0,0,'mech=0x26033FDB(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SLOW_ATTACK|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|INTERRUPT|DAZE|SAPPED)'),
(-295,0,0,1237221118,'','',0,0,'mech=0x24DF3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|SHACKLE|MOUNT|TURN|HORROR|DAZE|SAPPED)'),
(-294,64,0,1235123966,'','',0,0,'school=0x40(ARCANE), mech=0x24CF3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-293,0,0,1235123966,'114','11',0,0,'mech=0x24CF3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|SHACKLE|TURN|HORROR|DAZE|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-292,0,0,1235123966,'','',0,0,'mech=0x24CF3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-291,0,0,1235123964,'','',0,0,'mech=0x24CF3F7E(DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-290,0,0,1235119870,'114','11',0,0,'mech=0x24CF377F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|SHACKLE|TURN|HORROR|DAZE|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-289,0,0,1234992894,'','',0,0,'mech=0x24CE3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|SHIELD|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-288,0,0,1234632374,'','',0,0,'mech=0x24CB7F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BLEED|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-287,0,0,1234599678,'98,114,124,144,145','11',0,0,'mech=0x24CB3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED), flags=NO_TAUNT|IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),114(ATTACK_ME),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST), auras=11(BIND)'),
(-286,0,0,1234599678,'98,124,144,145','',0,0,'mech=0x24CB3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-285,0,0,1234599678,'','',0,0,'mech=0x24CB3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-284,0,0,1234599670,'','',0,0,'mech=0x24CB3F7B(CHARM|DISORIENTED|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-283,0,0,1234599654,'','',0,0,'mech=0x24CB3F73(CHARM|DISORIENTED|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-282,124,0,1234599614,'114','11',0,0,'school=0x7C(FIRE|NATURE|FROST|SHADOW|ARCANE), mech=0x24CB3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-281,0,0,1234599614,'114','11',0,0,'mech=0x24CB3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-280,0,0,1234599614,'','',0,0,'mech=0x24CB3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-279,0,0,1234599610,'','',0,0,'mech=0x24CB3F5D(CHARM|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-278,16,0,1234599606,'','',0,0,'school=0x10(FROST), mech=0x24CB3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-277,8,0,1234599606,'','',0,0,'school=0x8(NATURE), mech=0x24CB3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-276,4,0,1234599606,'','',0,0,'school=0x4(FIRE), mech=0x24CB3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-275,0,0,1234599606,'98,124,144,145','',0,0,'mech=0x24CB3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-274,0,0,1234599606,'114','11',0,0,'mech=0x24CB3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-273,0,0,1234599606,'','',0,0,'mech=0x24CB3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-272,0,0,1234599158,'','',0,0,'mech=0x24CB3E7B(CHARM|DISORIENTED|DISTRACT|FEAR|GRIP|ROOT|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-271,64,0,1234599094,'','',0,0,'school=0x40(ARCANE), mech=0x24CB3E5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-270,0,0,1234599094,'','',0,0,'mech=0x24CB3E5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-269,0,0,1234597430,'','',0,0,'mech=0x24CB3B1B(CHARM|DISORIENTED|DISTRACT|FEAR|SILENCE|SLEEP|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-268,0,0,1234595582,'','',0,0,'mech=0x24CB377F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-267,0,0,1234595518,'98,124,144,145','',0,0,'mech=0x24CB375F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-266,0,0,1234595518,'','',0,0,'mech=0x24CB375F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-265,0,0,1234595510,'','',0,0,'mech=0x24CB375B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-264,0,0,1234583806,'98,124,144,145','',0,0,'mech=0x24CB207F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-263,0,0,1234568766,'','',0,0,'mech=0x24CB031F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|SILENCE|SLEEP|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE|SAPPED)'),
(-262,0,0,1234075390,'','',0,0,'mech=0x24C73F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|TURN|HORROR|DAZE|SAPPED)'),
(-261,0,0,1233944318,'','',0,0,'mech=0x24C63F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|SHIELD|TURN|HORROR|DAZE|SAPPED)'),
(-260,0,0,1233551102,'','',0,0,'mech=0x24C33F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|TURN|HORROR|DAZE|SAPPED)'),
(-259,0,0,1233419518,'','',0,0,'mech=0x24C23E7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|TURN|HORROR|DAZE|SAPPED)'),
(-258,0,0,1233288886,'','',0,0,'mech=0x24C13F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|TURN|HORROR|DAZE|SAPPED)'),
(-257,0,0,1230420214,'','',0,0,'mech=0x24AB5C7B(CHARM|DISORIENTED|DISTRACT|FEAR|GRIP|ROOT|SNARE|STUN|FREEZE|BLEED|POLYMORPH|BANISH|SHACKLE|INFECTED|HORROR|DAZE|SAPPED)'),
(-256,0,0,1226211070,'98,124,144,145','',0,0,'mech=0x248B3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-255,4,0,1226211006,'','',0,0,'school=0x4(FIRE), mech=0x248B3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE|SAPPED)'),
(-254,0,0,1226211006,'','',0,0,'mech=0x248B3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE|SAPPED)'),
(-253,16,0,1226210998,'','',0,0,'school=0x10(FROST), mech=0x248B3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE|SAPPED)'),
(-252,4,0,1226210998,'','',0,0,'school=0x4(FIRE), mech=0x248B3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE|SAPPED)'),
(-251,0,0,1226210998,'','',0,0,'mech=0x248B3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE|SAPPED)'),
(-250,0,0,1226209974,'','',0,0,'mech=0x248B3D5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE|SAPPED)'),
(-249,0,0,1226209946,'','',0,0,'mech=0x248B3D4D(CHARM|DISARM|DISTRACT|ROOT|SILENCE|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE|SAPPED)'),
(-248,0,0,1226206902,'','',0,0,'mech=0x248B375B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE|SAPPED)'),
(-247,0,0,1225686782,'','',0,0,'mech=0x24873F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|HORROR|DAZE|SAPPED)'),
(-246,0,0,1225588478,'','',0,0,'mech=0x24867F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BLEED|BANISH|SHIELD|HORROR|DAZE|SAPPED)'),
(-245,0,0,1225424638,'','',0,0,'mech=0x24853F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|SHIELD|HORROR|DAZE|SAPPED)'),
(-244,0,0,1225293566,'','',0,0,'mech=0x24843F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|SHIELD|HORROR|DAZE|SAPPED)'),
(-243,0,0,1225162494,'','',0,0,'mech=0x24833F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|HORROR|DAZE|SAPPED)'),
(-242,0,0,1225064188,'','',0,0,'mech=0x24827F7E(DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BLEED|BANISH|HORROR|DAZE|SAPPED)'),
(-241,0,0,1225031358,'','',0,0,'mech=0x24823F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|HORROR|DAZE|SAPPED)'),
(-240,0,0,1225030910,'','',0,0,'mech=0x24823E7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|HORROR|DAZE|SAPPED)'),
(-239,0,0,1224899830,'','',0,0,'mech=0x24813E7B(CHARM|DISORIENTED|DISTRACT|FEAR|GRIP|ROOT|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|HORROR|DAZE|SAPPED)'),
(-238,0,0,1224899750,'','',0,0,'mech=0x24813E53(CHARM|DISORIENTED|FEAR|ROOT|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|HORROR|DAZE|SAPPED)'),
(-237,0,0,1224892158,'','',0,0,'mech=0x24812F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|KNOCKOUT|POLYMORPH|HORROR|DAZE|SAPPED)'),
(-236,0,0,1224891646,'98,124,144,145','',0,0,'mech=0x24812E7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SLEEP|SNARE|STUN|KNOCKOUT|POLYMORPH|HORROR|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-235,0,0,1224764576,'','',0,0,'mech=0x24803650(FEAR|ROOT|SLEEP|SNARE|FREEZE|KNOCKOUT|HORROR|DAZE|SAPPED)'),
(-234,0,0,1224758966,'','',0,0,'mech=0x24802B5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|STUN|KNOCKOUT|HORROR|DAZE|SAPPED)'),
(-233,0,0,1224758964,'','',0,0,'mech=0x24802B5A(DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|STUN|KNOCKOUT|HORROR|DAZE|SAPPED)'),
(-232,16,0,1217817766,'','',0,0,'school=0x10(FROST), mech=0x244B3653(CHARM|DISORIENTED|FEAR|ROOT|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|DAZE|SAPPED)'),
(-231,8,0,1217817766,'','',0,0,'school=0x8(NATURE), mech=0x244B3653(CHARM|DISORIENTED|FEAR|ROOT|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|DAZE|SAPPED)'),
(-230,0,0,1217817766,'114','11',0,0,'mech=0x244B3653(CHARM|DISORIENTED|FEAR|ROOT|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|DAZE|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-229,0,0,1217817766,'','',0,0,'mech=0x244B3653(CHARM|DISORIENTED|FEAR|ROOT|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|DAZE|SAPPED)'),
(-228,0,0,1209171606,'','',0,0,'mech=0x24093F4B(CHARM|DISORIENTED|DISTRACT|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|SHACKLE|DAZE|SAPPED)'),
(-227,0,0,1208245410,'','',0,0,'mech=0x24022E51(CHARM|FEAR|ROOT|SLEEP|SNARE|STUN|KNOCKOUT|BANISH|DAZE|SAPPED)'),
(-226,0,0,1208123134,'','',0,0,'mech=0x24013F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|DAZE|SAPPED)'),
(-225,0,0,1208118454,'','',0,0,'mech=0x2401365B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|DAZE|SAPPED)'),
(-224,0,0,1208117942,'','',0,0,'mech=0x2401355B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SNARE|FREEZE|KNOCKOUT|POLYMORPH|DAZE|SAPPED)'),
(-223,0,0,1208110334,'98,124,144,145','',0,0,'mech=0x2401267F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SLEEP|SNARE|KNOCKOUT|POLYMORPH|DAZE|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-222,0,0,1208099366,'','',0,0,'mech=0x24011113(CHARM|DISORIENTED|FEAR|SILENCE|FREEZE|POLYMORPH|DAZE|SAPPED)'),
(-221,0,0,1207983274,'','',0,0,'mech=0x24002E55(CHARM|DISARM|FEAR|ROOT|SLEEP|SNARE|STUN|KNOCKOUT|DAZE|SAPPED)'),
(-220,0,0,1167488694,'','',0,0,'mech=0x22CB3B5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|SAPPED)'),
(-219,0,0,1167484670,'98,124,144,145','',0,0,'mech=0x22CB337F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-218,0,0,1167484670,'','',0,0,'mech=0x22CB337F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|SAPPED)'),
(-217,0,0,1158053630,'114','11',0,0,'mech=0x22833F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|HORROR|INTERRUPT|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-216,0,0,1158053550,'','',0,0,'mech=0x22833F57(CHARM|DISORIENTED|DISARM|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|HORROR|INTERRUPT|SAPPED)'),
(-215,0,0,1158053548,'','',0,0,'mech=0x22833F56(DISORIENTED|DISARM|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|HORROR|INTERRUPT|SAPPED)'),
(-214,0,0,1141118466,'','',0,0,'mech=0x22020B01(CHARM|SILENCE|SLEEP|STUN|BANISH|INTERRUPT|SAPPED)'),
(-213,0,0,1102872318,'','',0,0,'mech=0x20DE3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|SHIELD|SHACKLE|MOUNT|TURN|HORROR|SAPPED)'),
(-212,0,0,1102477054,'','',0,0,'mech=0x20DB3B7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|MOUNT|TURN|HORROR|SAPPED)'),
(-211,0,0,1100906238,'98,124,144,145','',0,0,'mech=0x20CF3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|SHACKLE|TURN|HORROR|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-210,0,0,1100904190,'','',0,0,'mech=0x20CF3B7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|SHACKLE|TURN|HORROR|SAPPED)'),
(-209,0,0,1100379774,'','',0,0,'mech=0x20CB3B3F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|SILENCE|SLEEP|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|SAPPED)'),
(-208,0,0,1100379710,'','',0,0,'mech=0x20CB3B1F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|SILENCE|SLEEP|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|SAPPED)'),
(-207,0,0,1100375718,'','',0,0,'mech=0x20CB3353(CHARM|DISORIENTED|FEAR|ROOT|SILENCE|SLEEP|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|SAPPED)'),
(-206,0,0,1100366910,'','',0,0,'mech=0x20CB221F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|SLEEP|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|SAPPED)'),
(-205,0,0,1100359230,'','',0,0,'mech=0x20CB131F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|SILENCE|SLEEP|FREEZE|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|SAPPED)'),
(-204,0,0,1100350654,'','',0,0,'mech=0x20CB025F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SLEEP|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|SAPPED)'),
(-203,0,0,1100248830,'','',0,0,'mech=0x20CA3B7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|STUN|FREEZE|KNOCKOUT|BANISH|SHACKLE|TURN|HORROR|SAPPED)'),
(-202,0,0,1100219550,'','',0,0,'mech=0x20CA024F(CHARM|DISORIENTED|DISARM|DISTRACT|ROOT|SLEEP|BANISH|SHACKLE|TURN|HORROR|SAPPED)'),
(-201,0,0,1099060258,'','',0,0,'mech=0x20C12A11(CHARM|FEAR|SLEEP|STUN|KNOCKOUT|POLYMORPH|TURN|HORROR|SAPPED)'),
(-200,0,0,1099043874,'98,124,144,145','',0,0,'mech=0x20C10A11(CHARM|FEAR|SLEEP|STUN|POLYMORPH|TURN|HORROR|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-199,0,0,1099043874,'114','11',0,0,'mech=0x20C10A11(CHARM|FEAR|SLEEP|STUN|POLYMORPH|TURN|HORROR|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-198,0,0,1099043874,'','',0,0,'mech=0x20C10A11(CHARM|FEAR|SLEEP|STUN|POLYMORPH|TURN|HORROR|SAPPED)'),
(-197,0,0,1099040290,'','',0,0,'mech=0x20C10311(CHARM|FEAR|SILENCE|SLEEP|POLYMORPH|TURN|HORROR|SAPPED)'),
(-196,0,0,1090944766,'114','11',0,0,'mech=0x20833F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|HORROR|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-195,0,0,1090944766,'','',0,0,'mech=0x20833F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|HORROR|SAPPED)'),
(-194,0,0,1090944742,'114','11',0,0,'mech=0x20833F73(CHARM|DISORIENTED|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|HORROR|SAPPED), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-193,0,0,1090940670,'','',0,0,'mech=0x2083377F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|HORROR|SAPPED)'),
(-192,0,0,1090940076,'','',0,0,'mech=0x20833656(DISORIENTED|DISARM|FEAR|ROOT|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|HORROR|SAPPED)'),
(-191,0,0,1090936574,'','',0,0,'mech=0x20832F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|KNOCKOUT|POLYMORPH|BANISH|HORROR|SAPPED)'),
(-190,0,0,1090936558,'','',0,0,'mech=0x20832F77(CHARM|DISORIENTED|DISARM|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|KNOCKOUT|POLYMORPH|BANISH|HORROR|SAPPED)'),
(-189,0,0,1090681270,'','',0,0,'mech=0x20813CDB(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SLOW_ATTACK|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|HORROR|SAPPED)'),
(-188,0,0,1090681014,'','',0,0,'mech=0x20813C5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|HORROR|SAPPED)'),
(-187,0,0,1090679030,'','',0,0,'mech=0x2081387B(CHARM|DISORIENTED|DISTRACT|FEAR|GRIP|ROOT|STUN|FREEZE|KNOCKOUT|POLYMORPH|HORROR|SAPPED)'),
(-186,16,0,1090674362,'','',0,0,'school=0x10(FROST), mech=0x20812F5D(CHARM|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|KNOCKOUT|POLYMORPH|HORROR|SAPPED)'),
(-185,8,0,1090674362,'','',0,0,'school=0x8(NATURE), mech=0x20812F5D(CHARM|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|KNOCKOUT|POLYMORPH|HORROR|SAPPED)'),
(-184,0,0,1090534560,'','',0,0,'mech=0x20801E50(FEAR|ROOT|SLEEP|SNARE|STUN|FREEZE|HORROR|SAPPED)'),
(-183,0,0,1082686046,'','',0,0,'mech=0x20443D2F(CHARM|DISORIENTED|DISARM|DISTRACT|GRIP|SILENCE|SNARE|STUN|FREEZE|KNOCKOUT|SHIELD|TURN|SAPPED)'),
(-182,0,0,1082686030,'','',0,0,'mech=0x20443D27(CHARM|DISORIENTED|DISARM|GRIP|SILENCE|SNARE|STUN|FREEZE|KNOCKOUT|SHIELD|TURN|SAPPED)'),
(-181,0,0,1082677838,'','',0,0,'mech=0x20442D27(CHARM|DISORIENTED|DISARM|GRIP|SILENCE|SNARE|STUN|KNOCKOUT|SHIELD|TURN|SAPPED)'),
(-180,0,0,1075202048,'','',0,0,'mech=0x200B2400(SNARE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|SAPPED)'),
(-179,0,0,1074167486,'','',0,0,'mech=0x20033F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SAPPED)'),
(-178,0,0,1074009602,'','',0,0,'mech=0x20020B01(CHARM|SILENCE|SLEEP|STUN|BANISH|SAPPED)'),
(-177,0,0,1073886882,'98,124,144,145','',0,0,'mech=0x20011B51(CHARM|FEAR|ROOT|SILENCE|SLEEP|STUN|FREEZE|POLYMORPH|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-176,0,0,1073877158,'','',0,0,'mech=0x20010853(CHARM|DISORIENTED|FEAR|ROOT|STUN|POLYMORPH|SAPPED)'),
(-175,0,0,1073873920,'','',0,0,'mech=0x20010200(SLEEP|POLYMORPH|SAPPED)'),
(-174,0,0,1073758240,'','',0,0,'mech=0x20002010(FEAR|KNOCKOUT|SAPPED)'),
(-173,0,0,1073741892,'98,124,144,145','',0,0,'mech=0x20000022(DISORIENTED|GRIP|SAPPED), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-172,0,0,1073741826,'','',0,0,'mech=0x20000001(CHARM|SAPPED)'),
(-171,0,0,1073741824,'','',0,0,'mech=0x20000000(SAPPED)'),
(-170,0,0,1002405630,'','',0,0,'mech=0x1DDFBF7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANDAGE|POLYMORPH|BANISH|SHIELD|SHACKLE|MOUNT|TURN|HORROR|INVULNERABILITY|DAZE|DISCOVERY|IMMUNE_SHIELD)'),
(-169,0,0,688815860,'','',0,0,'mech=0x14873F7A(DISORIENTED|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHIELD|HORROR|DAZE|IMMUNE_SHIELD)'),
(-168,0,0,227962558,'','',0,0,'mech=0x6CB375F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|INTERRUPT|DAZE)'),
(-167,0,0,226915902,'','',0,0,'mech=0x6C33B1F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|SILENCE|SLEEP|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|TURN|HORROR|INTERRUPT|DAZE)'),
(-166,0,0,201719818,'98,124,144,145','',0,0,'mech=0x6030005(CHARM|DISARM|POLYMORPH|BANISH|INTERRUPT|DAZE), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-165,0,0,161361662,'','',0,0,'mech=0x4CF177F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|FREEZE|POLYMORPH|BANISH|SHIELD|SHACKLE|TURN|HORROR|DAZE)'),
(-164,0,0,160857854,'','',0,0,'mech=0x4CB3F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE)'),
(-163,0,0,160857790,'','',0,0,'mech=0x4CB3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE)'),
(-162,0,0,160857782,'','',0,0,'mech=0x4CB3F5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE)'),
(-161,0,0,160853686,'','',0,0,'mech=0x4CB375B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|TURN|HORROR|DAZE)'),
(-160,0,0,159516926,'','',0,0,'mech=0x4C1047F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SNARE|POLYMORPH|TURN|HORROR|DAZE)'),
(-159,0,0,152469182,'','',0,0,'mech=0x48B3F5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|BANISH|SHACKLE|HORROR|DAZE)'),
(-158,0,0,151401654,'','',0,0,'mech=0x4831A5B(CHARM|DISORIENTED|DISTRACT|FEAR|ROOT|SLEEP|STUN|FREEZE|POLYMORPH|BANISH|HORROR|DAZE)'),
(-157,0,0,151314166,'','',0,0,'mech=0x4826F7B(CHARM|DISORIENTED|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|KNOCKOUT|BLEED|BANISH|HORROR|DAZE)'),
(-156,0,0,151273214,'','',0,0,'mech=0x4821F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|BANISH|HORROR|DAZE)'),
(-155,0,0,151262398,'','',0,0,'mech=0x4820A5F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|ROOT|SLEEP|STUN|BANISH|HORROR|DAZE)'),
(-154,0,0,151262366,'','',0,0,'mech=0x4820A4F(CHARM|DISORIENTED|DISARM|DISTRACT|ROOT|SLEEP|STUN|BANISH|HORROR|DAZE)'),
(-153,0,0,151259878,'','',0,0,'mech=0x4820573(CHARM|DISORIENTED|FEAR|GRIP|ROOT|SILENCE|SNARE|BANISH|HORROR|DAZE)'),
(-152,0,0,142884558,'','',0,0,'mech=0x4421F67(CHARM|DISORIENTED|DISARM|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|BANISH|TURN|DAZE)'),
(-151,0,0,134610954,'98,124,144,145','',0,0,'mech=0x4030005(CHARM|DISARM|POLYMORPH|BANISH|DAZE), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-150,0,0,134487698,'','',0,0,'mech=0x4020F49(CHARM|DISTRACT|ROOT|SILENCE|SLEEP|SNARE|STUN|BANISH|DAZE)'),
(-149,0,0,134487696,'','',0,0,'mech=0x4020F48(DISTRACT|ROOT|SILENCE|SLEEP|SNARE|STUN|BANISH|DAZE)'),
(-148,0,0,134368350,'','',0,0,'mech=0x401262F(CHARM|DISORIENTED|DISARM|DISTRACT|GRIP|SLEEP|SNARE|KNOCKOUT|POLYMORPH|DAZE)'),
(-147,0,0,134221834,'','',0,0,'mech=0x4000805(CHARM|DISARM|STUN|DAZE)'),
(-146,0,0,134219776,'','',0,0,'mech=0x4000400(SNARE|DAZE)'),
(-145,0,0,134217728,'','',0,0,'mech=0x4000000(DAZE)'),
(-144,0,0,93617828,'114','11',0,0,'mech=0x2CA3F52(DISORIENTED|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|SHACKLE|TURN|HORROR|INTERRUPT), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-143,0,0,92569262,'98,124,144,145','',0,0,'mech=0x2C23F57(CHARM|DISORIENTED|DISARM|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|TURN|HORROR|INTERRUPT), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-142,0,0,92569262,'114','11',0,0,'mech=0x2C23F57(CHARM|DISORIENTED|DISARM|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|TURN|HORROR|INTERRUPT), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-141,0,0,92569262,'','',0,0,'mech=0x2C23F57(CHARM|DISORIENTED|DISARM|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|TURN|HORROR|INTERRUPT)'),
(-140,0,0,84180654,'','',0,0,'mech=0x2823F57(CHARM|DISORIENTED|DISARM|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|HORROR|INTERRUPT)'),
(-139,0,0,84180644,'','',0,0,'mech=0x2823F52(DISORIENTED|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BANISH|HORROR|INTERRUPT)'),
(-138,0,0,84032160,'98,124,144,145','',0,0,'mech=0x2811D50(FEAR|ROOT|SILENCE|SNARE|STUN|FREEZE|POLYMORPH|HORROR|INTERRUPT), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-137,0,0,75765794,'','',0,0,'mech=0x2420C11(CHARM|FEAR|SNARE|STUN|BANISH|TURN|INTERRUPT)'),
(-136,0,0,75502082,'','',0,0,'mech=0x2400901(CHARM|SILENCE|STUN|TURN|INTERRUPT)'),
(-135,0,0,75497986,'','',0,0,'mech=0x2400101(CHARM|SILENCE|TURN|INTERRUPT)'),
(-134,0,0,67272418,'','',0,0,'mech=0x2013F71(CHARM|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|POLYMORPH|INTERRUPT)'),
(-133,0,0,67109376,'','',0,0,'mech=0x2000100(SILENCE|INTERRUPT)'),
(-132,0,0,67108864,'','',0,0,'mech=0x2000000(INTERRUPT)'),
(-131,0,0,26216098,'','',0,0,'mech=0xC80351(CHARM|FEAR|ROOT|SILENCE|SLEEP|SHACKLE|TURN|HORROR)'),
(-130,0,0,25574434,'','',0,0,'mech=0xC31E11(CHARM|FEAR|SLEEP|SNARE|STUN|FREEZE|POLYMORPH|BANISH|TURN|HORROR)'),
(-129,0,0,25198706,'','',0,0,'mech=0xC04039(CHARM|DISTRACT|FEAR|GRIP|BLEED|TURN|HORROR)'),
(-128,0,0,17826336,'','',0,0,'mech=0x880110(FEAR|SILENCE|SHACKLE|HORROR)'),
(-127,0,0,17825826,'','',0,0,'mech=0x880011(CHARM|FEAR|SHACKLE|HORROR)'),
(-126,0,0,17825824,'','',0,0,'mech=0x880010(FEAR|SHACKLE|HORROR)'),
(-125,0,0,17204258,'','',0,0,'mech=0x834211(CHARM|FEAR|SLEEP|BLEED|POLYMORPH|BANISH|HORROR)'),
(-124,0,0,17171494,'','',0,0,'mech=0x830213(CHARM|DISORIENTED|FEAR|SLEEP|POLYMORPH|BANISH|HORROR)'),
(-123,124,0,17171490,'','',0,0,'school=0x7C(FIRE|NATURE|FROST|SHADOW|ARCANE), mech=0x830211(CHARM|FEAR|SLEEP|POLYMORPH|BANISH|HORROR)'),
(-122,0,0,17171490,'','',0,0,'mech=0x830211(CHARM|FEAR|SLEEP|POLYMORPH|BANISH|HORROR)'),
(-121,0,0,17045536,'','',0,0,'mech=0x820C10(FEAR|SNARE|STUN|BANISH|HORROR)'),
(-120,0,0,17041570,'','',0,0,'mech=0x820451(CHARM|FEAR|ROOT|SNARE|BANISH|HORROR)'),
(-119,0,0,17039520,'','',0,0,'mech=0x820050(FEAR|ROOT|BANISH|HORROR)'),
(-118,0,0,17039394,'','',0,0,'mech=0x820011(CHARM|FEAR|BANISH|HORROR)'),
(-117,0,0,16932526,'','',0,0,'mech=0x812F57(CHARM|DISORIENTED|DISARM|FEAR|ROOT|SILENCE|SLEEP|SNARE|STUN|KNOCKOUT|POLYMORPH|HORROR)'),
(-116,0,0,16827436,'','',0,0,'mech=0x806216(DISORIENTED|DISARM|FEAR|SLEEP|KNOCKOUT|BLEED|HORROR)'),
(-115,0,0,16794676,'','',0,0,'mech=0x80221A(DISORIENTED|DISTRACT|FEAR|SLEEP|KNOCKOUT|HORROR)'),
(-114,0,0,16794668,'','',0,0,'mech=0x802216(DISORIENTED|DISARM|FEAR|SLEEP|KNOCKOUT|HORROR)'),
(-113,0,0,16794664,'','',0,0,'mech=0x802214(DISARM|FEAR|SLEEP|KNOCKOUT|HORROR)'),
(-112,0,0,16791714,'','',0,0,'mech=0x801C51(CHARM|FEAR|ROOT|SNARE|STUN|FREEZE|HORROR)'),
(-111,0,0,16788644,'','',0,0,'mech=0x801652(DISORIENTED|FEAR|ROOT|SLEEP|SNARE|FREEZE|HORROR)'),
(-110,4,0,16784544,'','',0,0,'school=0x4(FIRE), mech=0x800E50(FEAR|ROOT|SLEEP|SNARE|STUN|HORROR)'),
(-109,0,0,16783392,'','',0,0,'mech=0x800C10(FEAR|SNARE|STUN|HORROR)'),
(-108,0,0,16781984,'','',0,0,'mech=0x800950(FEAR|ROOT|SILENCE|STUN|HORROR)'),
(-107,0,0,16781344,'','',0,0,'mech=0x800810(FEAR|STUN|HORROR)'),
(-106,0,0,16779488,'','',0,0,'mech=0x800470(FEAR|GRIP|ROOT|SNARE|HORROR)'),
(-105,0,0,16779428,'','',0,0,'mech=0x800452(DISORIENTED|FEAR|ROOT|SNARE|HORROR)'),
(-104,0,0,16779424,'','',0,0,'mech=0x800450(FEAR|ROOT|SNARE|HORROR)'),
(-103,0,0,16777376,'','',0,0,'mech=0x800050(FEAR|ROOT|HORROR)'),
(-102,0,0,16777312,'','',0,0,'mech=0x800030(FEAR|GRIP|HORROR)'),
(-101,0,0,16777264,'','',0,0,'mech=0x800018(DISTRACT|FEAR|HORROR)'),
(-100,0,0,16777250,'','',0,0,'mech=0x800011(CHARM|FEAR|HORROR)'),
(-99,127,0,16777248,'','',0,0,'school=0x7F(NORMAL|HOLY|FIRE|NATURE|FROST|SHADOW|ARCANE), mech=0x800010(FEAR|HORROR)'),
(-98,16,0,16777248,'','',0,0,'school=0x10(FROST), mech=0x800010(FEAR|HORROR)'),
(-97,1,0,16777248,'','',0,0,'school=0x1(NORMAL), mech=0x800010(FEAR|HORROR)'),
(-96,0,0,16777248,'98,124,144,145','',0,0,'mech=0x800010(FEAR|HORROR), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-95,0,0,16777248,'','',1,0,'mech=0x800010(FEAR|HORROR), flags=AVOID_AOE'),
(-94,0,0,16777248,'114','11',0,0,'mech=0x800010(FEAR|HORROR), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-93,0,0,16777248,'','',0,0,'mech=0x800010(FEAR|HORROR)'),
(-92,0,0,16777216,'','',0,0,'mech=0x800000(HORROR)'),
(-91,0,0,407714,'','',0,0,'mech=0x31C51(CHARM|FEAR|ROOT|SNARE|STUN|FREEZE|POLYMORPH|BANISH)'),
(-90,0,0,404998,'','',0,0,'mech=0x31703(CHARM|DISORIENTED|SILENCE|SLEEP|SNARE|FREEZE|POLYMORPH|BANISH)'),
(-89,0,0,278534,'','',0,0,'mech=0x22003(CHARM|DISORIENTED|KNOCKOUT|BANISH)'),
(-88,0,0,274592,'','',0,0,'mech=0x21850(FEAR|ROOT|STUN|FREEZE|BANISH)'),
(-87,0,0,265792,'98,124,144,145','',0,0,'mech=0x20720(GRIP|SILENCE|SLEEP|SNARE|BANISH), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-86,0,0,263222,'','',0,0,'mech=0x2021B(CHARM|DISORIENTED|DISTRACT|FEAR|SLEEP|BANISH)'),
(-85,0,0,262172,'','',0,0,'mech=0x2000E(DISORIENTED|DISARM|DISTRACT|BANISH)'),
(-84,0,0,262146,'114','11',0,0,'mech=0x20001(CHARM|BANISH), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-83,0,0,262146,'','',0,0,'mech=0x20001(CHARM|BANISH)'),
(-82,0,0,262144,'','',0,0,'mech=0x20000(BANISH)'),
(-81,0,0,143396,'','',0,0,'mech=0x11812(DISORIENTED|FEAR|STUN|FREEZE|POLYMORPH)'),
(-80,0,0,143360,'','',0,0,'mech=0x11800(STUN|FREEZE|POLYMORPH)'),
(-79,0,0,141440,'','',0,0,'mech=0x11440(ROOT|SNARE|FREEZE|POLYMORPH)'),
(-78,0,0,139298,'','',0,0,'mech=0x11011(CHARM|FEAR|FREEZE|POLYMORPH)'),
(-77,0,0,139264,'','',0,0,'mech=0x11000(FREEZE|POLYMORPH)'),
(-76,0,0,133250,'','',0,0,'mech=0x10441(CHARM|ROOT|SNARE|POLYMORPH)'),
(-75,0,0,133248,'','',0,0,'mech=0x10440(ROOT|SNARE|POLYMORPH)'),
(-74,0,0,133122,'','',0,0,'mech=0x10401(CHARM|SNARE|POLYMORPH)'),
(-73,0,0,132128,'','',0,0,'mech=0x10210(FEAR|SLEEP|POLYMORPH)'),
(-72,0,0,131106,'','',0,0,'mech=0x10011(CHARM|FEAR|POLYMORPH)'),
(-71,0,0,131074,'','',0,0,'mech=0x10001(CHARM|POLYMORPH)'),
(-70,0,0,131072,'','',0,0,'mech=0x10000(POLYMORPH)'),
(-69,0,0,65278,'','',0,0,'mech=0x7F7F(CHARM|DISORIENTED|DISARM|DISTRACT|FEAR|GRIP|ROOT|SILENCE|SLEEP|SNARE|STUN|FREEZE|KNOCKOUT|BLEED)'),
(-68,0,0,32768,'','',0,0,'mech=0x4000(BLEED)'),
(-67,0,0,21114,'','',0,0,'mech=0x293D(CHARM|DISARM|DISTRACT|FEAR|GRIP|SILENCE|STUN|KNOCKOUT)'),
(-66,16,0,20516,'','',0,0,'school=0x10(FROST), mech=0x2812(DISORIENTED|FEAR|STUN|KNOCKOUT)'),
(-65,0,0,20516,'','',0,0,'mech=0x2812(DISORIENTED|FEAR|STUN|KNOCKOUT)'),
(-64,4,0,17432,'','',0,0,'school=0x4(FIRE), mech=0x220C(DISARM|DISTRACT|SLEEP|KNOCKOUT)'),
(-63,4,0,17420,'','',0,0,'school=0x4(FIRE), mech=0x2206(DISORIENTED|DISARM|SLEEP|KNOCKOUT)'),
(-62,4,0,17416,'','',0,0,'school=0x4(FIRE), mech=0x2204(DISARM|SLEEP|KNOCKOUT)'),
(-61,0,0,16384,'','',0,0,'mech=0x2000(KNOCKOUT)'),
(-60,0,0,14982,'','',0,0,'mech=0x1D43(CHARM|DISORIENTED|ROOT|SILENCE|SNARE|STUN|FREEZE)'),
(-59,0,0,14374,'','',0,0,'mech=0x1C13(CHARM|DISORIENTED|FEAR|SNARE|STUN|FREEZE)'),
(-58,0,0,12584,'','',0,0,'mech=0x1894(DISARM|FEAR|SLOW_ATTACK|STUN|FREEZE)'),
(-57,64,0,10368,'','',0,0,'school=0x40(ARCANE), mech=0x1440(ROOT|SNARE|FREEZE)'),
(-56,0,0,8224,'','',0,0,'mech=0x1010(FEAR|FREEZE)'),
(-55,0,0,8192,'','',0,0,'mech=0x1000(FREEZE)'),
(-54,0,0,6810,'','',0,0,'mech=0xD4D(CHARM|DISARM|DISTRACT|ROOT|SILENCE|SNARE|STUN)'),
(-53,64,0,6688,'','',0,0,'school=0x40(ARCANE), mech=0xD10(FEAR|SILENCE|SNARE|STUN)'),
(-52,0,0,6178,'114','11',0,0,'mech=0xC11(CHARM|FEAR|SNARE|STUN), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-51,0,0,4624,'','',0,0,'mech=0x908(DISTRACT|SILENCE|STUN)'),
(-50,0,0,4608,'98,124,144,145','',0,0,'mech=0x900(SILENCE|STUN), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-49,0,0,4128,'114','11',0,0,'mech=0x810(FEAR|STUN), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-48,0,0,4114,'','',0,0,'mech=0x809(CHARM|DISTRACT|STUN)'),
(-47,0,0,4098,'','',0,0,'mech=0x801(CHARM|STUN)'),
(-46,0,0,4096,'','',0,0,'mech=0x800(STUN)'),
(-45,0,0,3074,'','',0,0,'mech=0x601(CHARM|SLEEP|SNARE)'),
(-44,0,0,2562,'','',0,0,'mech=0x501(CHARM|SILENCE|SNARE)'),
(-43,0,0,2210,'','',0,0,'mech=0x451(CHARM|FEAR|ROOT|SNARE)'),
(-42,0,0,2208,'','',0,0,'mech=0x450(FEAR|ROOT|SNARE)'),
(-41,0,0,2192,'','',0,0,'mech=0x448(DISTRACT|ROOT|SNARE)'),
(-40,0,0,2178,'','',0,0,'mech=0x441(CHARM|ROOT|SNARE)'),
(-39,0,0,2176,'','',0,0,'mech=0x440(ROOT|SNARE)'),
(-38,0,0,2082,'','',0,0,'mech=0x411(CHARM|FEAR|SNARE)'),
(-37,0,0,2080,'','',0,0,'mech=0x410(FEAR|SNARE)'),
(-36,0,0,2052,'','',0,0,'mech=0x402(DISORIENTED|SNARE)'),
(-35,0,0,2050,'','',0,0,'mech=0x401(CHARM|SNARE)'),
(-34,0,0,2048,'','',0,0,'mech=0x400(SNARE)'),
(-33,0,0,1058,'','',0,0,'mech=0x211(CHARM|FEAR|SLEEP)'),
(-32,0,0,1056,'','',0,0,'mech=0x210(FEAR|SLEEP)'),
(-31,0,0,1024,'','',0,0,'mech=0x200(SLEEP)'),
(-30,0,0,514,'114','11',0,0,'mech=0x101(CHARM|SILENCE), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-29,0,0,514,'','',0,0,'mech=0x101(CHARM|SILENCE)'),
(-28,0,0,512,'','',0,0,'mech=0x100(SILENCE)'),
(-27,0,0,64,'98,124,144,145','',0,0,'mech=0x20(GRIP), flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-26,0,0,64,'','',0,0,'mech=0x20(GRIP)'),
(-25,0,0,42,'114','11',0,0,'mech=0x15(CHARM|DISARM|FEAR), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-24,0,0,40,'','',0,0,'mech=0x14(DISARM|FEAR)'),
(-23,0,0,34,'','',0,0,'mech=0x11(CHARM|FEAR)'),
(-22,64,0,32,'','',0,0,'school=0x40(ARCANE), mech=0x10(FEAR)'),
(-21,4,0,32,'','',0,0,'school=0x4(FIRE), mech=0x10(FEAR)'),
(-20,0,0,32,'','',0,0,'mech=0x10(FEAR)'),
(-19,0,0,18,'114','11',0,0,'mech=0x9(CHARM|DISTRACT), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-18,0,0,18,'','',0,0,'mech=0x9(CHARM|DISTRACT)'),
(-17,16,0,16,'','',0,0,'school=0x10(FROST), mech=0x8(DISTRACT)'),
(-16,8,0,16,'','',0,0,'school=0x8(NATURE), mech=0x8(DISTRACT)'),
(-15,0,0,16,'','',0,0,'mech=0x8(DISTRACT)'),
(-14,0,0,4,'','',0,0,'mech=0x2(DISORIENTED)'),
(-13,0,0,2,'114','11',0,0,'mech=0x1(CHARM), flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(-12,0,0,2,'','',0,0,'mech=0x1(CHARM)'),
(-11,126,0,0,'','',0,0,'school=0x7E(HOLY|FIRE|NATURE|FROST|SHADOW|ARCANE)'),
(-10,116,0,0,'','',0,0,'school=0x74(FIRE|FROST|SHADOW|ARCANE)'),
(-9,96,0,0,'','',0,0,'school=0x60(SHADOW|ARCANE)'),
(-8,64,0,0,'','',0,0,'school=0x40(ARCANE)'),
(-7,32,0,0,'','',0,0,'school=0x20(SHADOW)'),
(-6,16,0,0,'','',0,0,'school=0x10(FROST)'),
(-5,8,0,0,'','',0,0,'school=0x8(NATURE)'),
(-4,4,0,0,'','',0,0,'school=0x4(FIRE)'),
(-3,0,0,0,'98,124,144,145','',0,0,'flags=IMMUNITY_KNOCKBACK, effects=98(KNOCK_BACK),124(PULL_TOWARDS),144(KNOCK_BACK_DEST),145(PULL_TOWARDS_DEST)'),
(-2,0,0,0,'','',1,0,'flags=AVOID_AOE'),
(-1,0,0,0,'114','11',0,0,'flags=NO_TAUNT, effects=114(ATTACK_ME), auras=11(BIND)'),
(95,0,0,32,'','',0,0,'Immune to Fear'),
(96,0,0,1234599598,'','5,7,12,26,33',0,0,'Immune to CC (Free Friend, Uncontrollable Frenzy, Warlord\'s Presence)'),
(315,127,0,0,'','',0,0,'Immune to damage'),
(477,0,0,68719476736,'114','11',0,0,'Immune to Taunt'),
(679,0,0,1234599078,'','5,7,12,26,33',0,0,'Immune to CC'),
(878,0,0,4096,'','12',0,0,'Immune to Stun'),
(1537,0,0,32,'','7',0,0,'Immune to Fear'),
(1557,0,0,1234599078,'98,124,144,145','',0,0,'Immune CC+Stun+Move'),
(1614,0,0,68719476736,'114','11',0,0,'Immune to Taunt'),
(1615,0,0,1301707942,'68','5,7,12,26,27,33,60',0,0,'Immune to CC+Interrupt'),
(1630,0,0,69954075814,'114','5,7,11,12,26,33',0,0,'Immune to CC+Taunt'),
(1632,0,0,584472182,'','',0,0,'Immune to hard CC'),
(1664,0,0,2176,'','',0,0,'Immune to slow'),
(1676,0,0,570425344,'','',0,0,'Immune to immunities'),
(1682,0,0,4096,'','',0,0,'Immune to Stun'),
(1693,0,0,584472182,'68','',0,0,'Immune to hard CC+Interrupt'),
(1694,0,0,68719476736,'114','11',0,0,'Immune to Taunt'),
(1695,0,0,4096,'','12',0,0,'Immune to Stun'),
(1733,0,0,1234599078,'98,124,144,145','',0,0,'Immune CC+Stun+Move'),
(1762,0,4,0,'','',0,0,'Immune to Curses'),
(1771,0,0,0,'','',1,0,'Immune to AoE'),
(1794,0,0,67108864,'68','',0,0,'Immune to Interrupts'),
(1799,0,0,67109376,'68','27,60',0,0,'Immune to Interrupts+Silence'),
(1808,0,0,0,'98,124,144,145','',0,0,'Immune to knockback'),
(1835,0,0,1075183616,'','',0,0,'Immune to polymorph+banish+shackle+sap'),
(1887,0,0,0,'98,124,144,145','',0,0,'Immune to knockback'),
(1893,0,0,1091728546,'','',0,0,'Immune to Charm+Fear+Freeze+Horror+Knockout+Polymorph+Root+Sap+Shackle+Sleep+Stun'),
(1944,0,0,68719476736,'114','11',0,0,'Immune to Taunt'),
(1964,0,0,67109376,'68','27,60',0,0,'Immune to Interrupts+Silence'),
(1971,0,0,4096,'','',0,0,'Immune to Stun');
/*!40000 ALTER TABLE `creature_immunities` ENABLE KEYS */;
UNLOCK TABLES;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2026-06-01 22:39:39
+2
View File
@@ -39,6 +39,8 @@
"coa:reference:test": "node --test scripts/coa-reference/*.test.mjs",
"coa:reference:promote": "node scripts/coa-reference/cli.mjs promote",
"coa:catalogs:refresh": "vite-node scripts/generate-combat-runtime-catalogs.ts --coa-only && node scripts/export-coa-triggered-spells.mjs && vite-node scripts/generate-combat-runtime-catalogs.ts --coa-only && vite-node scripts/generate-catalog-shards.ts --coa-only && node scripts/export-spell-icons.mjs --missing-only",
"coa:party:prepare": "vite-node --mode balance-lab scripts/coa-first-party/prepare.ts",
"coa:party:test": "vitest run src/game/benchmark/coaFirstParty.test.ts",
"ascension:talents": "node scripts/generate-wow335-talents.mjs ../wow335a/Interface/AddOns/PlayerBotManager/PBM/PBM_TalentData.lua ../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Spell.dbc src/game/wow335Talents.generated.ts ../LadiksMPQEditor/client-current/area-52/patch-D/DBFilesClient/Talent.dbc",
"catalogs:shard": "vite-node scripts/generate-catalog-shards.ts",
"talents:generate": "vite-node scripts/generate-party-runtime-talents.ts",
@@ -13,25 +13,25 @@
"assets": [
{
"sourceModel": "model/character/pc/human/male/act_hm_male.ros",
"appearanceSignature": "69f689e11a88cb1e42ce379e58dc489df08481892be02bfad63ca0a647a4a0ca",
"appearanceSignature": "ffec5e2c934ab099fb11de4af35b430d41dba77f0d59da47532c9dd938f5c131",
"sourceModelSha256": "cb87870362e4005f48bf9b3f14e3ac82b70b6ec73ed0f7b500c4ac86c24c54b8",
"checksum": "61313c6f4dcac6074884f4d25548e9fbb08d728ef4879cdefdf4372a17542bf5",
"size": 9091192,
"triangles": 2175,
"checksum": "9c06e55dead90b30cfa95f18640bdce5a04e638cac5ae87e9fbb986802f581a3",
"size": 9440020,
"triangles": 2195,
"bounds": {
"min": [
-0.49463,
-0.00016,
-0.10764
0.00001,
-0.12407
],
"max": [
0.49464,
1.73199,
0.20969
0.20274
]
},
"groundOffset": 0.00016,
"labelHeight": 2.08215,
"groundOffset": -0.00001,
"labelHeight": 2.08198,
"markerRadius": 0.4,
"animations": [
"Attack - 1h_attack01",
@@ -217,7 +217,7 @@
"Idle - ride_idle05"
],
"paperdollAssembly": "scripts/runewaker-pipeline/recipes/player-starter-appearance.tsv",
"paperdollAssemblySha256": "69f689e11a88cb1e42ce379e58dc489df08481892be02bfad63ca0a647a4a0ca",
"paperdollAssemblySha256": "ffec5e2c934ab099fb11de4af35b430d41dba77f0d59da47532c9dd938f5c131",
"texturePolicy": "source-native modular starter parts",
"id": "rom-human-male",
"baseActorId": "rom-human-male",
@@ -227,15 +227,15 @@
},
{
"sourceModel": "model/character/pc/human/female/act_hm_female.ros",
"appearanceSignature": "69f689e11a88cb1e42ce379e58dc489df08481892be02bfad63ca0a647a4a0ca",
"appearanceSignature": "ffec5e2c934ab099fb11de4af35b430d41dba77f0d59da47532c9dd938f5c131",
"sourceModelSha256": "f22c0ae13e2eba986ee0cbe79773fca6657a6e854637700d82ea1fbbd8ee44c5",
"checksum": "132bc85eb371759edaf8c5f3b026418b9e86c083c0666a38d384c76cda34a319",
"size": 9892052,
"triangles": 2965,
"checksum": "70fd91ce09a37b318ec10ea29157c86fd7ebb40a246ec7237f03070a3071b4be",
"size": 10421276,
"triangles": 3009,
"bounds": {
"min": [
-0.29993,
-0.00067,
-0.00083,
-0.18277
],
"max": [
@@ -244,8 +244,8 @@
0.16713
]
},
"groundOffset": 0.00067,
"labelHeight": 1.996,
"groundOffset": 0.00083,
"labelHeight": 1.99616,
"markerRadius": 0.4,
"animations": [
"Attack - 1h_attack01",
@@ -431,7 +431,7 @@
"Idle - ride_idle05"
],
"paperdollAssembly": "scripts/runewaker-pipeline/recipes/player-starter-appearance.tsv",
"paperdollAssemblySha256": "69f689e11a88cb1e42ce379e58dc489df08481892be02bfad63ca0a647a4a0ca",
"paperdollAssemblySha256": "ffec5e2c934ab099fb11de4af35b430d41dba77f0d59da47532c9dd938f5c131",
"texturePolicy": "source-native modular starter parts",
"id": "rom-human-female",
"baseActorId": "rom-human-female",
@@ -441,25 +441,25 @@
},
{
"sourceModel": "model/character/pc/elf/male/act_elf_male.ros",
"appearanceSignature": "69f689e11a88cb1e42ce379e58dc489df08481892be02bfad63ca0a647a4a0ca",
"appearanceSignature": "ffec5e2c934ab099fb11de4af35b430d41dba77f0d59da47532c9dd938f5c131",
"sourceModelSha256": "0cabaab34dd22d09c58a53491898ec1c0216c031519cff3447e03d8c981362c6",
"checksum": "1e8e49ba5040193cc557a84fb315d565c3d50abe643d419314b08bc2f295c666",
"size": 13877552,
"triangles": 2302,
"checksum": "f529f07422c376dc2137f89615b89ef8693f3a1a98da1ae628e3a6e6f2adbe06",
"size": 13760264,
"triangles": 2322,
"bounds": {
"min": [
-0.49934,
-0.00016,
-0.11116
0.00001,
-0.11266
],
"max": [
0.49934,
1.86205,
0.2056
0.20046
]
},
"groundOffset": 0.00016,
"labelHeight": 2.21221,
"groundOffset": -0.00001,
"labelHeight": 2.21204,
"markerRadius": 0.4,
"animations": [
"Attack - 1h_attack01",
@@ -637,7 +637,7 @@
"Idle - ride_idle05"
],
"paperdollAssembly": "scripts/runewaker-pipeline/recipes/player-starter-appearance.tsv",
"paperdollAssemblySha256": "69f689e11a88cb1e42ce379e58dc489df08481892be02bfad63ca0a647a4a0ca",
"paperdollAssemblySha256": "ffec5e2c934ab099fb11de4af35b430d41dba77f0d59da47532c9dd938f5c131",
"texturePolicy": "source-native modular starter parts",
"id": "rom-elf-male",
"baseActorId": "rom-elf-male",
@@ -647,25 +647,25 @@
},
{
"sourceModel": "model/character/pc/elf/female/act_elf_female.ros",
"appearanceSignature": "69f689e11a88cb1e42ce379e58dc489df08481892be02bfad63ca0a647a4a0ca",
"appearanceSignature": "ffec5e2c934ab099fb11de4af35b430d41dba77f0d59da47532c9dd938f5c131",
"sourceModelSha256": "843a8f32bbc149878277dd5c377e8843e610239c667b59cdb4a6589df8b57498",
"checksum": "2e1c384e33e7f69731b9230d12545f191b8b2b684da3226ea42a83d0b00c17a4",
"size": 12464244,
"triangles": 2863,
"checksum": "a3f92aefc6bc6364001abbd9b9722f4aa63c152e3a7c8978daedecd6f169176b",
"size": 12500220,
"triangles": 2907,
"bounds": {
"min": [
-0.29998,
0.00017,
0.00006,
-0.16475
],
"max": [
0.29998,
1.69733,
0.17273
0.17272
]
},
"groundOffset": -0.00017,
"labelHeight": 2.04716,
"groundOffset": -0.00006,
"labelHeight": 2.04727,
"markerRadius": 0.4,
"animations": [
"Attack - 1h_attack01",
@@ -847,7 +847,7 @@
"Idle - ride_idle05"
],
"paperdollAssembly": "scripts/runewaker-pipeline/recipes/player-starter-appearance.tsv",
"paperdollAssemblySha256": "69f689e11a88cb1e42ce379e58dc489df08481892be02bfad63ca0a647a4a0ca",
"paperdollAssemblySha256": "ffec5e2c934ab099fb11de4af35b430d41dba77f0d59da47532c9dd938f5c131",
"texturePolicy": "source-native modular starter parts",
"id": "rom-elf-female",
"baseActorId": "rom-elf-female",
@@ -857,25 +857,25 @@
},
{
"sourceModel": "model/character/pc/dwarf/male/act_dwarf_male.ros",
"appearanceSignature": "69f689e11a88cb1e42ce379e58dc489df08481892be02bfad63ca0a647a4a0ca",
"appearanceSignature": "ffec5e2c934ab099fb11de4af35b430d41dba77f0d59da47532c9dd938f5c131",
"sourceModelSha256": "f508da5779fcce5fc6fe802ab01ade1557e2f1d10cc8bc8426ab389fa07657f2",
"checksum": "fc1a89213cdae3d95b1a936f4eccbe663513a217de8e06d6405bbd1b5e506a0f",
"size": 9558708,
"triangles": 2334,
"checksum": "6155a5afb1b7cb7434cb6b09fd83893fd4aa6b3a196901f52bff056d84b96b2e",
"size": 9832856,
"triangles": 2354,
"bounds": {
"min": [
-0.49384,
-0.00691,
-0.0055,
-0.27076
],
"max": [
0.49663,
1.36264,
0.27377
0.26651
]
},
"groundOffset": 0.00691,
"labelHeight": 1.71955,
"groundOffset": 0.0055,
"labelHeight": 1.71814,
"markerRadius": 0.4,
"animations": [
"Attack - 1h_attack01",
@@ -1053,7 +1053,7 @@
"Idle - ride_idle05"
],
"paperdollAssembly": "scripts/runewaker-pipeline/recipes/player-starter-appearance.tsv",
"paperdollAssemblySha256": "69f689e11a88cb1e42ce379e58dc489df08481892be02bfad63ca0a647a4a0ca",
"paperdollAssemblySha256": "ffec5e2c934ab099fb11de4af35b430d41dba77f0d59da47532c9dd938f5c131",
"texturePolicy": "source-native modular starter parts",
"id": "rom-dwarf-male",
"baseActorId": "rom-dwarf-male",
@@ -1,6 +1,6 @@
{
"version": 1,
"sourceBuild": "runewaker-v1-a5ff8cd412f4fba7",
"sourceBuild": "runewaker-v1-516f02ed8beaec00",
"publicRoot": "/assets/characters/runewaker-v1",
"textureLayout": {
"width": 1,
@@ -46,9 +46,9 @@
"dbcSexId": 0,
"sourceModel": "model/character/pc/human/male/act_hm_male.ros",
"model": "/assets/characters/runewaker-v1/rom-human-male.glb",
"bytes": 9091192,
"bytes": 9440020,
"scale": 1,
"groundOffset": 0.00016,
"groundOffset": -0.00001,
"rotationY": 0,
"materialTokens": {
"body": "",
@@ -1349,9 +1349,9 @@
]
},
"provenance": {
"glbSha256": "61313c6f4dcac6074884f4d25548e9fbb08d728ef4879cdefdf4372a17542bf5",
"glbSha256": "9c06e55dead90b30cfa95f18640bdce5a04e638cac5ae87e9fbb986802f581a3",
"sourceModelSha256": "cb87870362e4005f48bf9b3f14e3ac82b70b6ec73ed0f7b500c4ac86c24c54b8",
"assemblySha256": "69f689e11a88cb1e42ce379e58dc489df08481892be02bfad63ca0a647a4a0ca",
"assemblySha256": "ffec5e2c934ab099fb11de4af35b430d41dba77f0d59da47532c9dd938f5c131",
"nativeClipCount": 181
}
},
@@ -1362,9 +1362,9 @@
"dbcSexId": 1,
"sourceModel": "model/character/pc/human/female/act_hm_female.ros",
"model": "/assets/characters/runewaker-v1/rom-human-female.glb",
"bytes": 9892052,
"bytes": 10421276,
"scale": 1,
"groundOffset": 0.00067,
"groundOffset": 0.00083,
"rotationY": 0,
"materialTokens": {
"body": "",
@@ -2665,9 +2665,9 @@
]
},
"provenance": {
"glbSha256": "132bc85eb371759edaf8c5f3b026418b9e86c083c0666a38d384c76cda34a319",
"glbSha256": "70fd91ce09a37b318ec10ea29157c86fd7ebb40a246ec7237f03070a3071b4be",
"sourceModelSha256": "f22c0ae13e2eba986ee0cbe79773fca6657a6e854637700d82ea1fbbd8ee44c5",
"assemblySha256": "69f689e11a88cb1e42ce379e58dc489df08481892be02bfad63ca0a647a4a0ca",
"assemblySha256": "ffec5e2c934ab099fb11de4af35b430d41dba77f0d59da47532c9dd938f5c131",
"nativeClipCount": 181
}
},
@@ -2678,9 +2678,9 @@
"dbcSexId": 0,
"sourceModel": "model/character/pc/elf/male/act_elf_male.ros",
"model": "/assets/characters/runewaker-v1/rom-elf-male.glb",
"bytes": 13877552,
"bytes": 13760264,
"scale": 1,
"groundOffset": 0.00016,
"groundOffset": -0.00001,
"rotationY": 0,
"materialTokens": {
"body": "",
@@ -3981,9 +3981,9 @@
]
},
"provenance": {
"glbSha256": "1e8e49ba5040193cc557a84fb315d565c3d50abe643d419314b08bc2f295c666",
"glbSha256": "f529f07422c376dc2137f89615b89ef8693f3a1a98da1ae628e3a6e6f2adbe06",
"sourceModelSha256": "0cabaab34dd22d09c58a53491898ec1c0216c031519cff3447e03d8c981362c6",
"assemblySha256": "69f689e11a88cb1e42ce379e58dc489df08481892be02bfad63ca0a647a4a0ca",
"assemblySha256": "ffec5e2c934ab099fb11de4af35b430d41dba77f0d59da47532c9dd938f5c131",
"nativeClipCount": 173
}
},
@@ -3994,9 +3994,9 @@
"dbcSexId": 1,
"sourceModel": "model/character/pc/elf/female/act_elf_female.ros",
"model": "/assets/characters/runewaker-v1/rom-elf-female.glb",
"bytes": 12464244,
"bytes": 12500220,
"scale": 1,
"groundOffset": -0.00017,
"groundOffset": -0.00006,
"rotationY": 0,
"materialTokens": {
"body": "",
@@ -5297,9 +5297,9 @@
]
},
"provenance": {
"glbSha256": "2e1c384e33e7f69731b9230d12545f191b8b2b684da3226ea42a83d0b00c17a4",
"glbSha256": "a3f92aefc6bc6364001abbd9b9722f4aa63c152e3a7c8978daedecd6f169176b",
"sourceModelSha256": "843a8f32bbc149878277dd5c377e8843e610239c667b59cdb4a6589df8b57498",
"assemblySha256": "69f689e11a88cb1e42ce379e58dc489df08481892be02bfad63ca0a647a4a0ca",
"assemblySha256": "ffec5e2c934ab099fb11de4af35b430d41dba77f0d59da47532c9dd938f5c131",
"nativeClipCount": 177
}
},
@@ -5310,9 +5310,9 @@
"dbcSexId": 0,
"sourceModel": "model/character/pc/dwarf/male/act_dwarf_male.ros",
"model": "/assets/characters/runewaker-v1/rom-dwarf-male.glb",
"bytes": 9558708,
"bytes": 9832856,
"scale": 1,
"groundOffset": 0.00691,
"groundOffset": 0.0055,
"rotationY": 0,
"materialTokens": {
"body": "",
@@ -6613,9 +6613,9 @@
]
},
"provenance": {
"glbSha256": "fc1a89213cdae3d95b1a936f4eccbe663513a217de8e06d6405bbd1b5e506a0f",
"glbSha256": "6155a5afb1b7cb7434cb6b09fd83893fd4aa6b3a196901f52bff056d84b96b2e",
"sourceModelSha256": "f508da5779fcce5fc6fe802ab01ade1557e2f1d10cc8bc8426ab389fa07657f2",
"assemblySha256": "69f689e11a88cb1e42ce379e58dc489df08481892be02bfad63ca0a647a4a0ca",
"assemblySha256": "ffec5e2c934ab099fb11de4af35b430d41dba77f0d59da47532c9dd938f5c131",
"nativeClipCount": 173
}
}
@@ -0,0 +1,194 @@
{
"schemaVersion": 2,
"dungeonId": "runewaker-starter-weapons",
"status": "green",
"animationPolicy": "authentic ROS models with original sampled RAS skeletal animations",
"paperdollCatalog": {
"file": "scripts/runewaker-pipeline/snapshots/runewaker-paperdoll-appearances.json",
"contentSha256": "ad6561b73e52abb5f3a2c1389f76412bf2b1eece7fe4b50542bd67f0101940c4",
"appearanceAssets": 0,
"unresolvedColorLayerBakes": 0
},
"proceduralFallbacks": [],
"assets": [
{
"sourceModel": "model/item/weapon/blade/act_blade01_000_001.ros",
"appearanceSignature": null,
"sourceModelSha256": "c35658bc44c7dd1c2b891aa12b5454336ef2bd8f0471ae8851313a93652d4a62",
"checksum": "06fae35ef45019a40e7a158c2f024d4a57e739bafca594a0da6834636326ea33",
"size": 35608,
"triangles": 126,
"bounds": {
"min": [
-0.02628,
-0.14332,
-0.13491
],
"max": [
0.02512,
0.9093,
0.13491
]
},
"groundOffset": 0.14332,
"labelHeight": 1.40262,
"markerRadius": 0.4,
"animations": [],
"id": "starter-sword",
"baseActorId": "starter-sword",
"fileName": "starter-sword.glb",
"url": "/assets/equipment/runewaker-v1/starter-sword.glb",
"rotationY": 0
},
{
"sourceModel": "model/item/weapon/hammer/act_hammer01_000_001.ros",
"appearanceSignature": null,
"sourceModelSha256": "055ed0fe0a8a6cc8bca09f5e978164e1b73c4f9a4aa8e0ecffe1e9e351a5961d",
"checksum": "c5287270cb1bccec154b2614be5b7da520e9c750fb928c0ee512daeca0184759",
"size": 36732,
"triangles": 118,
"bounds": {
"min": [
-0.10684,
-0.13211,
-0.14915
],
"max": [
0.10684,
0.47292,
0.14915
]
},
"groundOffset": 0.13211,
"labelHeight": 0.95503,
"markerRadius": 0.4,
"animations": [],
"id": "starter-mace",
"baseActorId": "starter-mace",
"fileName": "starter-mace.glb",
"url": "/assets/equipment/runewaker-v1/starter-mace.glb",
"rotationY": 0
},
{
"sourceModel": "model/item/weapon/shield/act_shield01_000_001.ros",
"appearanceSignature": null,
"sourceModelSha256": "8d8b8d8a74abc1bb186330af39ac02de9a4280fa6e4e35949e31bc70396c5791",
"checksum": "c8e44772012757bb9c9120556a011d39b9713e93632024f751b268a4f4fdf5e2",
"size": 36656,
"triangles": 96,
"bounds": {
"min": [
-0.21232,
-0.21232,
-0.03973
],
"max": [
0.21232,
0.21232,
0.03424
]
},
"groundOffset": 0.21232,
"labelHeight": 0.77464,
"markerRadius": 0.4,
"animations": [],
"id": "starter-shield",
"baseActorId": "starter-shield",
"fileName": "starter-shield.glb",
"url": "/assets/equipment/runewaker-v1/starter-shield.glb",
"rotationY": 0
},
{
"sourceModel": "model/item/weapon/staff/act_staff00_000_001.ros",
"appearanceSignature": null,
"sourceModelSha256": "3654529f2d2455aeda1aa57dc269b083dbe5ac28ad1c0c964a2062516f76c802",
"checksum": "9c5a68349eeaa348ffd13e3a69a8534aa9a80bf4f6ad0dfc99b658635c92f64e",
"size": 109388,
"triangles": 68,
"bounds": {
"min": [
-0.03978,
-0.67587,
-0.05265
],
"max": [
0.03978,
0.71679,
0.03923
]
},
"groundOffset": 0.67587,
"labelHeight": 1.74266,
"markerRadius": 0.4,
"animations": [],
"id": "starter-staff",
"baseActorId": "starter-staff",
"fileName": "starter-staff.glb",
"url": "/assets/equipment/runewaker-v1/starter-staff.glb",
"rotationY": 0
},
{
"sourceModel": "model/item/weapon/bow/act_bow03_010_001.ros",
"appearanceSignature": null,
"sourceModelSha256": "42749cfe6ffce9b5d32d2adfb75b0e9d2ee4ac6deba7b83fee582451a1ab261b",
"checksum": "e9466dbba6f47822d3999a55d32ef48b2fbbba2abe9539d1db7e22f574f7d801",
"size": 34252,
"triangles": 88,
"bounds": {
"min": [
-0.01878,
-0.6096,
-0.16323
],
"max": [
0.01943,
0.65058,
0.01795
]
},
"groundOffset": 0.6096,
"labelHeight": 1.61018,
"markerRadius": 0.4,
"animations": [
"Action - bow_draw",
"Action - bow_hold",
"Idle - bow_idle",
"Action - bow_shoot"
],
"id": "starter-bow",
"baseActorId": "starter-bow",
"fileName": "starter-bow.glb",
"url": "/assets/equipment/runewaker-v1/starter-bow.glb",
"rotationY": 0
},
{
"sourceModel": "model/item/weapon/dagger/act_dagger01_010_005.ros",
"appearanceSignature": null,
"sourceModelSha256": "29904ebaa54e2789ebc84d71b46142cd348361cf335feb14111222d7ca4578a9",
"checksum": "cb6cb309f5efef9b38cc85de741d3110abed2450f690675ae2da71f4bd9cfb85",
"size": 32252,
"triangles": 92,
"bounds": {
"min": [
-0.02584,
-0.11076,
-0.08163
],
"max": [
0.02584,
0.56015,
0.08163
]
},
"groundOffset": 0.11076,
"labelHeight": 1.02091,
"markerRadius": 0.4,
"animations": [],
"id": "starter-dagger",
"baseActorId": "starter-dagger",
"fileName": "starter-dagger.glb",
"url": "/assets/equipment/runewaker-v1/starter-dagger.glb",
"rotationY": 0
}
]
}
Binary file not shown.
+79
View File
@@ -0,0 +1,79 @@
import { NodeIO } from "@gltf-transform/core";
import { BufferGeometry, Float32BufferAttribute, Matrix4, Vector3 } from "three";
import { requireDungeonDefinition } from "../src/game/dungeonRegistry";
import {
buildPartyNavigationGraph,
findNearestPartyNavigationSurface,
findPartyNavigationPath,
} from "../src/game/partyNavigation";
const dungeonIds = process.argv.slice(2).filter((argument) => !argument.startsWith("--"));
if (!dungeonIds.length) throw new Error("Pass at least one dungeon id.");
async function navigationGraph(dungeonId: string) {
const dungeon = requireDungeonDefinition(dungeonId);
const navigation = dungeon.assets.navigation;
if (!navigation) throw new Error(`${dungeonId} has no navigation asset.`);
const file = `src/assets/game/dungeons/${dungeonId}/${navigation.fileName}`;
const document = await new NodeIO().read(file);
const positions: number[] = [];
const vertex = new Vector3();
for (const node of document.getRoot().listNodes()) {
const mesh = node.getMesh();
if (!mesh) continue;
const world = new Matrix4().fromArray(node.getWorldMatrix());
for (const primitive of mesh.listPrimitives()) {
const source = primitive.getAttribute("POSITION")?.getArray();
if (!source) continue;
const indices = primitive.getIndices()?.getArray()
?? Array.from({ length: source.length / 3 }, (_, index) => index);
for (const index of indices) {
vertex.fromArray(source, Number(index) * 3).applyMatrix4(world);
positions.push(vertex.x, vertex.y, vertex.z);
}
}
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
try {
return buildPartyNavigationGraph(geometry);
} finally {
geometry.dispose();
}
}
for (const dungeonId of dungeonIds) {
const dungeon = requireDungeonDefinition(dungeonId);
const boss = dungeon.bosses.find((candidate) => !candidate.eventOnly);
if (!boss) throw new Error(`${dungeonId} has no normal campaign boss.`);
const graph = await navigationGraph(dungeonId);
const start = findNearestPartyNavigationSurface(graph, dungeon.entrance.footPosition, 3);
const end = findNearestPartyNavigationSurface(graph, boss.position, 6);
if (!start || !end) throw new Error(`${dungeonId} entrance or ${boss.name} does not snap to navigation.`);
const path = findPartyNavigationPath(graph, start.position, end.position, {
maxStartSnapDistance: 3,
maxEndSnapDistance: 6,
simplifyTolerance: 0.01,
});
if (!path?.length) throw new Error(`${dungeonId} has no navigation path to ${boss.name}.`);
let expectedDistance = 0;
for (let index = 1; index < path.length; index += 1) {
expectedDistance += Math.hypot(
path[index][0] - path[index - 1][0],
path[index][1] - path[index - 1][1],
path[index][2] - path[index - 1][2],
);
}
process.stdout.write(`${JSON.stringify({
dungeonId,
status: "navigation-guidance-only; live UI traversal required",
objective: boss.name,
entrance: start.position,
objectivePosition: end.position,
startSnapDistance: start.distance,
objectiveSnapDistance: end.distance,
waypointCount: path.length,
expectedNavigationDistance: Number(expectedDistance.toFixed(2)),
waypoints: path,
})}\n`);
}
+152
View File
@@ -0,0 +1,152 @@
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { abilitiesForClass, abilityTalentNodeId } from "../../src/game/abilityCatalog";
import { abilityHasRotationSemantics, rotationProfileFor } from "../../src/game/combatRotation";
import { BOSS_TESTING_CATALOG } from "../../src/game/bossTesting";
import {
coaClassDefinition,
coaEntriesForClass,
coaSpellById,
COA_LIVE_SOURCE,
} from "../../src/game/coaLiveCatalog";
import {
COA_FIRST_PARTY_MEMBERS,
coaFirstPartySimulationConfig,
loadCoaFirstPartyBuilds,
} from "../../src/game/benchmark/coaFirstParty";
import type { CoaClassId } from "../../src/app/characterCatalog";
const root = fileURLToPath(new URL("../../", import.meta.url));
const args = process.argv.slice(2);
let level = 60;
let listDungeon: string | undefined;
const bossKeys: string[] = [];
for (let index = 0; index < args.length; index += 2) {
const flag = args[index];
const value = args[index + 1];
if (!value || value.startsWith("--")) throw new Error(`Missing value for ${flag}.`);
if (flag === "--level") level = Number(value);
else if (flag === "--boss") bossKeys.push(value);
else if (flag === "--list-bosses") listDungeon = value;
else throw new Error(`Unknown argument ${flag}.`);
}
if (listDungeon) {
if (bossKeys.length) throw new Error("Use either --list-bosses or explicit --boss keys.");
const bosses = BOSS_TESTING_CATALOG.filter((boss) => boss.dungeonId === listDungeon && boss.category === "wow");
if (!bosses.length) throw new Error(`No WoW bosses found for ${listDungeon}.`);
console.log(JSON.stringify(bosses.map(({ key, name }) => ({ key, name })), null, 2));
} else {
// An explicit boss list prevents preparation from becoming an all-boss run.
const selectedBosses = bossKeys.map((key) => {
const boss = BOSS_TESTING_CATALOG.find((candidate) => candidate.key === key && candidate.category === "wow");
if (!boss) throw new Error(`Unknown WoW boss key: ${key}.`);
return { key: boss.key, name: boss.name };
});
const config = coaFirstPartySimulationConfig(level, bossKeys);
const builds = await loadCoaFirstPartyBuilds(level);
const sourceBytes = await readFile(resolve(root, "src/game/coaLiveData.generated.json"));
const sourceSha256 = createHash("sha256").update(sourceBytes).digest("hex");
const classes = COA_FIRST_PARTY_MEMBERS.map((member, slot) => {
const classId = member.classId as CoaClassId;
const definition = coaClassDefinition(classId);
const abilities = abilitiesForClass(classId);
const build = builds[slot];
const firstPassTabs = new Set(definition.tabs
.filter((tab) => tab.tabName === "Class" || tab.tabName === member.specialization)
.map((tab) => tab.tabId));
const nodes = coaEntriesForClass(classId).map((entry) => {
const nodeId = `coa-entry-${entry.id}`;
const mapped = abilities.filter((ability) => abilityTalentNodeId(ability) === nodeId);
const effects = mapped.flatMap((ability) => [
...ability.effects,
...(ability.ranks ?? []).flatMap((rank) => rank.effects),
]);
const spellIds = [...new Set(entry.rankDescriptions.map((rank) => rank.spellId))];
return {
nodeId,
sourceNodeId: entry.id,
name: entry.name,
tree: definition.tabs.find((tab) => tab.tabId === entry.tabId)?.tabName ?? String(entry.tabId),
phase: firstPassTabs.has(entry.tabId) ? "first-party" : "remaining-trees",
description: entry.description,
rankDescriptions: entry.rankDescriptions,
spellIds,
missingGlobalSpellIds: spellIds.filter((id) => !coaSpellById(id)?.dbc),
selectedRank: build.talentRanks[nodeId] ?? 0,
runtimeAbilityIds: mapped.map((ability) => ability.id),
effectKinds: [...new Set(effects.map((effect) => effect.kind))].sort(),
hasRotationSemantics: mapped.some(abilityHasRotationSemantics),
sourceOperationsNeedingReview: effects.filter((effect) => (
effect.kind === "scripted" || effect.kind === "unsupported"
|| (effect.kind === "utility" && effect.action === "class-script")
)),
// A catalog match cannot establish working class mechanics.
mechanicVerification: "needs-review",
};
});
return {
...member,
slot,
buildId: build.id,
talentRanks: build.talentRanks,
talentAllocation: "generated role-weighted diagnostic build; requires authored review",
rotationProfileId: rotationProfileFor(classId, member.specializationId, member.specialization)?.id ?? null,
readiness: "needs-testing",
counts: {
classNodes: nodes.length,
firstPartyNodes: nodes.filter((node) => node.phase === "first-party").length,
nodesWithoutRuntimeAbility: nodes.filter((node) => node.runtimeAbilityIds.length === 0).length,
nodesWithRetainedSourceOperations: nodes.filter((node) => node.sourceOperationsNeedingReview.length > 0).length,
sourceGapNodes: nodes.filter((node) => node.missingGlobalSpellIds.length > 0).length,
},
nodes,
};
});
const report = {
schemaVersion: 1,
milestone: "coa-first-party",
level,
source: {
realm: COA_LIVE_SOURCE.realmName,
capturedAt: COA_LIVE_SOURCE.synchronizedAt,
catalogSha256: sourceSha256,
spellDbcPath: COA_LIVE_SOURCE.coaSpellDbcPath,
},
scope: "Explicit five-class diagnostic; no readiness promotion or balance certification.",
bosses: selectedBosses,
classes,
};
const output = resolve(root, "artifacts/coa-first-party", `level-${level}`);
await mkdir(output, { recursive: true });
const saveJson = (name: string, value: unknown) => writeFile(resolve(output, name), `${JSON.stringify(value, null, 2)}\n`);
await saveJson("audit.json", report);
await saveJson("config.json", config);
for (let slot = 0; slot < COA_FIRST_PARTY_MEMBERS.length; slot += 1) {
await saveJson(`seat-${slot + 1}.json`, coaFirstPartySimulationConfig(level, bossKeys, slot));
}
const markdown = [
"# First CoA party — mechanic review inventory",
"",
`Level ${level}; ${COA_LIVE_SOURCE.realmName} capture ${COA_LIVE_SOURCE.synchronizedAt}.`,
"Catalog mappings below are review inventory, not implemented-mechanic counts.",
"All five remain Needs Testing. Generated talent allocations need authored review.",
"",
"| Class | Specialization | Role | All class nodes | First-party nodes | Nodes with retained source operations | Global source-gap nodes |",
"| --- | --- | --- | ---: | ---: | ---: | ---: |",
...classes.map((entry) => `| ${entry.classId} | ${entry.specialization} | ${entry.role} | ${entry.counts.classNodes} | ${entry.counts.firstPartyNodes} | ${entry.counts.nodesWithRetainedSourceOperations} | ${entry.counts.sourceGapNodes} |`),
"",
"## Repeatable diagnostics",
"",
`Bosses: ${selectedBosses.map((boss) => `${boss.name} (${boss.key})`).join(", ")}.`,
"Five seat configurations keep the same classes and build IDs while moving the active player.",
"The full node checklist, spell IDs, descriptions, and source-operation review flags are in audit.json.",
"No encounter simulation runs during preparation.",
"",
`Source catalog SHA-256: ${sourceSha256}.`,
].join("\n");
await writeFile(resolve(output, "audit.md"), `${markdown}\n`);
console.log(JSON.stringify({ output, classes: classes.map(({ classId, specialization, counts }) => ({ classId, specialization, counts })), configurations: 5, simulatedEncounters: 0 }, null, 2));
}
@@ -1005,6 +1005,8 @@ function parseSpells(buffer, wantedIds, durations = new Map(), radii = new Map()
name: name.replace(/\|c[0-9a-f]{8}|\|r/gi, "").trim() || `Spell ${id}`,
recoveryTimeMs: unsigned(29),
school: schoolFor(unsigned(225)),
schoolMask: unsigned(225),
damageClass: unsigned(213),
durationMs: durations.get(unsigned(40)) ?? 0,
effects: Array.from({ length: 3 }, (_, effectIndex) => ({
effectType: unsigned(71 + effectIndex),
@@ -1246,6 +1248,12 @@ function spellAttack(spell, hash, boss) {
delivery: target === "self" ? "area" : area ? "area" : melee ? "melee" : "projectile",
target,
school: spell.school,
schoolMask: spell.schoolMask,
damageOrigin: "wow335",
damageClass: spell.damageClass,
...(spell.damageClass === 1 ? { attackKind: "spell" }
: spell.damageClass === 2 ? { attackKind: "melee" }
: spell.damageClass === 3 ? { attackKind: "ranged" } : {}),
animation: melee ? "attack" : "cast",
range: target === "self" ? 0 : melee ? 5.5 : area ? 22 : 28,
cooldownMs: Math.max(2_500, Math.min(12_000, spell.recoveryTimeMs || 4_000 + hash % 3_500)),
@@ -0,0 +1,285 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { 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 campaignFile = path.join(projectRoot, "src/game/generated/dungeonCampaignCatalog.json");
const bossMechanicsFile = path.join(projectRoot, "dungeon-pipeline/boss-mechanics.json");
const runewakerSpellFile = path.join(
projectRoot,
"scripts/runewaker-pipeline/snapshots/runewaker-npc-spell-catalog.json",
);
const outputFile = path.join(projectRoot, "src/game/generated/enemyDamageTypeSources.generated.json");
const defaultWowDbc = path.resolve(
projectRoot,
"../wow-pvt-server-backups-2/wow335a/Data/patch-A.MPQ/DBFilesClient/Spell.dbc",
);
const DAMAGE_EFFECTS = new Set([1, 2, 7, 9, 17, 31, 58, 62, 121]);
const WOW_SCHOOL_BITS = [
[1, "physical"],
[2, "holy"],
[4, "fire"],
[8, "nature"],
[16, "frost"],
[32, "shadow"],
[64, "arcane"],
];
const ROM_CHANNELS = ["earth", "water", "fire", "wind", "light", "darkness"];
const ROM_RUNTIME_SCHOOLS = ["nature", "frost", "fire", "nature", "holy", "shadow"];
const ROM_PHYSICAL_RUNTIME_SCHOOLS = { physical: "physical" };
const ROM_PHYSICAL_CALCULATION_TYPES = new Set([0, 2, 3, 9]);
const ROM_MAGICAL_CALCULATION_TYPES = new Set([1, 10]);
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function readWord(index, fieldCount, localizedShift) {
const sourceField = index >= 77 ? index + localizedShift : index;
assert(sourceField < fieldCount, `Spell.dbc field ${sourceField} is outside the record.`);
return sourceField;
}
function parseWowDamageRecords(buffer, wantedIds) {
assert(buffer.length >= 20 && buffer.toString("ascii", 0, 4) === "WDBC", "Spell.dbc is not WDBC data.");
const recordCount = buffer.readUInt32LE(4);
const fieldCount = buffer.readUInt32LE(8);
const recordSize = buffer.readUInt32LE(12);
const stringSize = buffer.readUInt32LE(16);
assert(fieldCount >= 226 && recordSize >= fieldCount * 4, "Spell.dbc lacks required 3.3.5 fields.");
assert(20 + recordCount * recordSize + stringSize <= buffer.length, "Spell.dbc has invalid record/string bounds.");
const localizedShift = fieldCount >= 239 ? 6 : 0;
const stringsOffset = 20 + recordCount * recordSize;
const results = new Map();
const readName = (base) => {
for (let locale = 0; locale < 16; locale += 1) {
const field = readWord(136 + locale, fieldCount, localizedShift);
const offset = buffer.readUInt32LE(base + field * 4);
if (!offset || offset >= stringSize) continue;
const start = stringsOffset + offset;
const end = buffer.indexOf(0, start);
const value = buffer.toString("utf8", start, end < 0 ? buffer.length : end).replace(/\s+/g, " ").trim();
if (value) return value;
}
return "";
};
for (let row = 0; row < recordCount; row += 1) {
const base = 20 + row * recordSize;
const spellId = buffer.readUInt32LE(base);
if (!wantedIds.has(spellId)) continue;
const unsigned = (field) => buffer.readUInt32LE(base + readWord(field, fieldCount, localizedShift) * 4);
const effectIds = Array.from({ length: 3 }, (_, index) => unsigned(71 + index));
const damageEffects = effectIds.filter((effectId) => DAMAGE_EFFECTS.has(effectId));
// Utility/script spells such as Call Flames are not source damage merely
// because a DBC record has a school. Keep their authored runtime school.
if (!damageEffects.length) continue;
const schoolMask = unsigned(225);
const schools = WOW_SCHOOL_BITS.filter(([bit]) => (schoolMask & bit) !== 0).map(([, school]) => school);
const damageClass = unsigned(213);
results.set(spellId, {
spellId,
name: readName(base),
damageClass,
schoolMask,
schools,
...(schools.length === 1 ? { school: schools[0] } : {}),
...(damageClass === 1 ? { attackKind: "spell" }
: damageClass === 2 ? { attackKind: "melee" }
: damageClass === 3 ? { attackKind: "ranged" } : {}),
damageEffects: [...new Set(damageEffects)].sort((left, right) => left - right),
source: "WoW 3.3.5a Spell.dbc; AzerothCore DBCStructure.h fields 213/225",
});
}
return { records: results, recordCount, fieldCount, recordSize };
}
function spellIdsFromCampaign(campaign) {
const ids = new Set();
let inspectedAttackCount = 0;
for (const definition of campaign.definitions ?? []) {
for (const entity of Object.values(definition.entities ?? {})) {
for (const attack of entity.combat?.attacks ?? []) {
const spellId = Number(attack.spellId);
if (Number.isSafeInteger(spellId) && spellId > 0) {
ids.add(spellId);
inspectedAttackCount += 1;
}
}
}
}
return { ids, inspectedAttackCount };
}
function spellIdsFromBossMechanics(catalog) {
const ids = new Set();
for (const boss of Object.values(catalog.bosses ?? {})) {
// This catalog also contains Runewaker boss entries. Their numeric IDs
// belong to a different spell namespace and must never be matched in DBC.
if (boss.source?.kind === "runewaker") continue;
for (const mechanic of boss.mechanics ?? []) {
const spellId = Number(mechanic.spellId);
if (Number.isSafeInteger(spellId) && spellId > 0) ids.add(spellId);
}
}
return ids;
}
function runewakerSourceDamageClass(calculationType) {
if (ROM_PHYSICAL_CALCULATION_TYPES.has(Number(calculationType))) return "physical";
if (ROM_MAGICAL_CALCULATION_TYPES.has(Number(calculationType))) return "magical";
return undefined;
}
function runewakerDirectFormula(calculationType) {
switch (Number(calculationType)) {
case 2: return "throw";
case 4: return "fixed";
case 8: return "target-hp-percent";
default: return undefined;
}
}
function runewakerFormulaMitigation(calculationType, effectType, ignoreMagicAbsorb, isFixDotPower) {
if (ignoreMagicAbsorb || (Number(calculationType) === 8 && isFixDotPower)) return "bypassed";
switch (Number(calculationType)) {
case 2: return "weapon-absorb";
case 4: return "magic-absorb";
case 8: return Number(effectType) === 1 ? "weapon-absorb" : "magic-absorb";
default: return undefined;
}
}
function runewakerAttackKind(calculationType) {
const value = Number(calculationType);
if (value === 0 || value === 9) return "melee";
if (value === 2) return "ranged";
if (value === 3) return "ranged";
if (value === 1 || value === 10) return "spell";
return undefined;
}
function runewakerSourceChannel(magicType) {
const value = Number(magicType);
if (value === -1) return "physical";
return ROM_CHANNELS[value];
}
function runewakerRuntimeSchool(sourceChannel) {
if (sourceChannel === "physical") return ROM_PHYSICAL_RUNTIME_SCHOOLS.physical;
const index = ROM_CHANNELS.indexOf(sourceChannel);
return index >= 0 ? ROM_RUNTIME_SCHOOLS[index] : undefined;
}
function romRecord(spell) {
const components = (spell.components ?? [])
.filter((component) => component.directHpDamage)
.map((component) => {
const rawChannel = Number(component.magicType);
const sourceDamageClass = runewakerSourceDamageClass(component.calculationType);
const sourceChannel = runewakerSourceChannel(rawChannel);
const school = runewakerRuntimeSchool(sourceChannel);
return {
componentId: Number(component.id),
calculationType: Number(component.calculationType),
effectType: Number(component.effectType),
magicType: rawChannel,
...(runewakerDirectFormula(component.calculationType)
? { directFormula: runewakerDirectFormula(component.calculationType) } : {}),
...(runewakerFormulaMitigation(component.calculationType, component.effectType, component.ignoreMagicAbsorb, component.isFixDotPower)
? { formulaMitigation: runewakerFormulaMitigation(component.calculationType, component.effectType, component.ignoreMagicAbsorb, component.isFixDotPower) } : {}),
...(sourceDamageClass ? { sourceDamageClass } : {}),
...(runewakerAttackKind(component.calculationType)
? { attackKind: runewakerAttackKind(component.calculationType) } : {}),
...(sourceChannel ? { sourceChannel } : {}),
...(school ? { school } : {}),
};
});
if (!components.length) return null;
return {
spellId: Number(spell.spellId),
name: spell.name,
components,
source: "RuneWaker v0.20 MagicObjectDB; MagicResistENUM in ObjectStruct.h",
};
}
function parseArguments(argv) {
let wowDbc = defaultWowDbc;
for (let index = 0; index < argv.length; index += 1) {
if (argv[index] === "--wow-dbc") {
wowDbc = path.resolve(argv[index + 1] ?? "");
index += 1;
}
}
return { wowDbc };
}
const { wowDbc } = parseArguments(process.argv.slice(2));
const [campaignBytes, bossMechanicsBytes, runewakerSpellBytes, wowDbcBytes] = await Promise.all([
readFile(campaignFile),
readFile(bossMechanicsFile),
readFile(runewakerSpellFile),
readFile(wowDbc),
]);
const campaign = JSON.parse(campaignBytes.toString("utf8"));
const bossMechanics = JSON.parse(bossMechanicsBytes.toString("utf8"));
const runewakerCatalog = JSON.parse(runewakerSpellBytes.toString("utf8"));
const campaignIds = spellIdsFromCampaign(campaign);
const wowSpellIds = new Set([...campaignIds.ids, ...spellIdsFromBossMechanics(bossMechanics)]);
const wow = parseWowDamageRecords(wowDbcBytes, wowSpellIds);
const runewaker = Object.fromEntries((runewakerCatalog.spells ?? [])
.map((spell) => [String(spell.spellId), romRecord(spell)])
.filter(([, record]) => record !== null));
const output = {
schemaVersion: 1,
generatedFrom: {
wowSpellDbc: path.relative(projectRoot, wowDbc).replaceAll(path.sep, "/"),
wowSpellDbcSha256: sha256(wowDbcBytes),
runewakerNpcSpellCatalog: path.relative(projectRoot, runewakerSpellFile).replaceAll(path.sep, "/"),
runewakerNpcSpellCatalogSha256: sha256(runewakerSpellBytes),
wowRecordLayout: `${wow.fieldCount} fields; ${wow.recordSize} byte rows`,
wowSpellDamageFields: { damageClass: 213, schoolMask: 225, effects: [71, 72, 73] },
runewakerCalculationTypePolicy: {
physical: [0, 3, 9],
magical: [1, 10],
directFormulaTypes: { "2": "throw", "4": "fixed", "8": "target-hp-percent" },
formulaMitigationIsIndependentOfMagicTypeAndCollectEffectType: true,
attackKind: { "0": "melee", "1": "spell", "3": "ranged", "9": "melee", "10": "spell" },
unsupportedTypesRemainUnclassified: true,
},
runewakerChannelEnum: "MagicType -1 is physical; values 0..5 are Earth, Water, Fire, Wind, Light, Darkness",
},
policy: {
wowLookupScope: "spell ids used by the current compiled campaign enemy attacks or boss mechanics",
wowDamageEffectsOnly: [...DAMAGE_EFFECTS].sort((left, right) => left - right),
wowCompositeMaskPolicy: "preserve recognized schools; export `school` only for a single-bit mask",
runewakerLookupScope: "NPC spell catalog entries with positively classified direct HP damage components",
runewakerCompatibilitySchoolPolicy: "source packet class comes from MagicObject.Attack.CalType; exact MagicType channel is preserved independently and mapped to a best-fit runtime school",
sourceIdentityKey: "origin plus spellId; RuneWaker numeric ids are never matched against WoW entries",
},
counts: {
wowCandidateSpellIds: wowSpellIds.size,
wowExportedDamageSpells: wow.records.size,
wowDirectDamageEffectSpells: [...wow.records.values()].filter((entry) => entry.damageEffects.includes(2)).length,
wowCompositeSchoolMasks: [...wow.records.values()].filter((entry) => entry.schools.length > 1).length,
runewakerCatalogSpells: (runewakerCatalog.spells ?? []).length,
runewakerExportedDamageSpells: Object.keys(runewaker).length,
runewakerDamageComponents: Object.values(runewaker).reduce((sum, entry) => sum + entry.components.length, 0),
campaignEnemyAttackReferences: campaignIds.inspectedAttackCount,
},
wow335: Object.fromEntries([...wow.records.entries()]
.sort(([left], [right]) => left - right)
.map(([spellId, record]) => [String(spellId), record])),
runewaker: Object.fromEntries(Object.entries(runewaker).sort(([left], [right]) => Number(left) - Number(right))),
};
await writeFile(outputFile, JSON.stringify(output, null, 2) + "\n", "utf8");
console.log(`[enemy-damage-types] exported ${output.counts.wowExportedDamageSpells} WoW and ${output.counts.runewakerExportedDamageSpells} RuneWaker source spells.`);
console.log(`[enemy-damage-types] ${path.relative(projectRoot, outputFile)}`);
@@ -41,6 +41,20 @@ const epochDungeonSourceFile = path.join(
*/
export const DEFAULT_AZEROTHCORE_COMMIT = "4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3";
export const PINNED_CREATURE_IMMUNITIES_SOURCE = Object.freeze({
repository: "https://github.com/azerothcore/azerothcore-wotlk",
commit: DEFAULT_AZEROTHCORE_COMMIT,
path: "data/sql/base/db_world/creature_immunities.sql",
url: `https://raw.githubusercontent.com/azerothcore/azerothcore-wotlk/${DEFAULT_AZEROTHCORE_COMMIT}/data/sql/base/db_world/creature_immunities.sql`,
sha256: "aee81a1d8b99986f8498a2b4c51b64eaf4b9095a0d1a12bbf3705d9588a90a13",
fixtureRoot: path.join(
projectRoot,
"dungeon-pipeline",
"source-fixtures",
`azerothcore-${DEFAULT_AZEROTHCORE_COMMIT}`,
),
});
const SOURCE_TABLES = Object.freeze([
"creature",
"creature_template",
@@ -303,16 +317,37 @@ function bitMaskNames(maskValue, names, bitOffset = 0) {
));
}
function creatureTemplateReference(row) {
export function creatureTemplateReference(row, creatureImmunitiesById = new Map()) {
const entry = numeric(firstValue(row, ["entry", "CreatureID", "creatureId"]));
const mechanicImmuneMask = numeric(firstValue(
row,
["mechanic_immune_mask", "mechanicImmuneMask", "CreatureImmunitiesId"],
));
const spellSchoolImmuneMask = numeric(firstValue(
const legacyMechanicImmuneMask = firstValue(row, ["mechanic_immune_mask", "mechanicImmuneMask"]);
const creatureImmunitiesId = firstValue(row, ["CreatureImmunitiesId", "creatureImmunitiesId"]);
let mechanicImmuneMask = numeric(legacyMechanicImmuneMask);
let spellSchoolImmuneMask = numeric(firstValue(
row,
["spell_school_immune_mask", "spellSchoolImmuneMask"],
));
if (legacyMechanicImmuneMask === undefined && creatureImmunitiesId !== undefined) {
const immunityId = numeric(creatureImmunitiesId);
if (immunityId !== 0) {
const immunity = creatureImmunitiesById.get(immunityId);
if (!immunity) {
throw new Error(
`Creature ${entry} references missing creature_immunities.ID ${immunityId}.`,
);
}
const sourceMechanicsMask = numeric(firstValue(immunity, ["MechanicsMask", "mechanicsMask"]));
if (!Number.isSafeInteger(sourceMechanicsMask) || sourceMechanicsMask < 0) {
throw new Error(
`creature_immunities.ID ${immunityId} has an unsupported MechanicsMask ${sourceMechanicsMask}.`,
);
}
// CreatureImmunities.Mechanic stores bit N at the mechanic enum value N.
// The legacy creature_template mask stores bit N-1. Normalize back to
// that established runtime representation before decoding names below.
mechanicImmuneMask = Math.floor(sourceMechanicsMask / 2);
spellSchoolImmuneMask = numeric(firstValue(immunity, ["SchoolMask", "schoolMask"]));
}
}
return {
entry,
name: String(firstValue(row, ["name", "Name"]) ?? `Creature ${entry}`),
@@ -589,7 +624,13 @@ function parseColumns(sql, tableName) {
return columns;
}
async function parseTable(sourceRoot, tableName, visitor) {
export function creatureTemplateUsesImmunitiesReference(sql) {
return parseColumns(sql, "creature_template").some((column) => (
column === "CreatureImmunitiesId" || column === "creatureImmunitiesId"
));
}
export async function parseTable(sourceRoot, tableName, visitor) {
const file = path.join(sourceRoot, `${tableName}.sql`);
const buffer = await readFile(file);
const sql = buffer.toString("utf8");
@@ -627,6 +668,27 @@ async function fetchSources(sourceRoot, commit) {
sha256: sha256(buffer),
});
}
const creatureTemplateSql = (await readFile(path.join(sourceRoot, "creature_template.sql"))).toString("utf8");
if (creatureTemplateUsesImmunitiesReference(creatureTemplateSql)) {
const tableName = "creature_immunities";
const url = `${base}/${tableName}.sql`;
const response = await fetch(url, {
headers: { "User-Agent": "healer-man-dungeon-pipeline" },
});
if (!response.ok) {
throw new Error(`${tableName}: ${response.status} ${response.statusText} while fetching ${url}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const file = path.join(sourceRoot, `${tableName}.sql`);
await writeFile(file, buffer);
reports.push({
table: tableName,
url,
file: path.relative(projectRoot, file).replace(/\\/g, "/"),
byteLength: buffer.length,
sha256: sha256(buffer),
});
}
await writeJson(path.join(sourceRoot, "source.lock.json"), {
schemaVersion: 1,
repository: "https://github.com/azerothcore/azerothcore-wotlk",
@@ -857,6 +919,42 @@ export async function importAzerothCoreWorld({
}
}));
const creatureImmunitiesById = new Map();
const creatureTemplateSql = (await readFile(
path.join(resolvedSourceRoot, "creature_template.sql"),
)).toString("utf8");
if (creatureTemplateUsesImmunitiesReference(creatureTemplateSql)) {
let creatureImmunitiesSourceRoot = resolvedSourceRoot;
try {
await readFile(path.join(creatureImmunitiesSourceRoot, "creature_immunities.sql"));
} catch (error) {
if (error?.code !== "ENOENT" || commit !== PINNED_CREATURE_IMMUNITIES_SOURCE.commit) throw error;
creatureImmunitiesSourceRoot = PINNED_CREATURE_IMMUNITIES_SOURCE.fixtureRoot;
}
const creatureImmunitiesReport = await parseTable(
creatureImmunitiesSourceRoot,
"creature_immunities",
(row) => {
const id = numeric(firstValue(row, ["ID", "id"]));
if (creatureImmunitiesById.has(id)) {
throw new Error(`creature_immunities contains duplicate ID ${id}.`);
}
creatureImmunitiesById.set(id, row);
},
);
if (commit === PINNED_CREATURE_IMMUNITIES_SOURCE.commit
&& creatureImmunitiesReport.sha256 !== PINNED_CREATURE_IMMUNITIES_SOURCE.sha256) {
throw new Error(
`creature_immunities.sql does not match pinned ${commit} SHA-256 `
+ `${PINNED_CREATURE_IMMUNITIES_SOURCE.sha256}.`,
);
}
tableReports.push({
...creatureImmunitiesReport,
url: `https://raw.githubusercontent.com/azerothcore/azerothcore-wotlk/${commit}/data/sql/base/db_world/creature_immunities.sql`,
});
}
const templateByEntry = new Map();
tableReports.push(await parseTable(resolvedSourceRoot, "creature_template", (row) => {
const entry = numeric(firstValue(row, ["entry", "CreatureID", "creatureId"]));
@@ -1137,7 +1235,7 @@ export async function importAzerothCoreWorld({
const normalizedTemplates = Object.fromEntries(
[...templateByEntry.entries()]
.sort(([left], [right]) => left - right)
.map(([entry, row]) => [entry, creatureTemplateReference(row)]),
.map(([entry, row]) => [entry, creatureTemplateReference(row, creatureImmunitiesById)]),
);
const normalizedCreatureClassStats = Object.fromEntries(
creatureClassLevelStats
@@ -1170,9 +1268,10 @@ export async function importAzerothCoreWorld({
}
}
const sourceFiles = tableReports.map(({ file, columns, rows, byteLength, sha256: hash }) => ({
const sourceFiles = tableReports.map(({ file, columns, rows, byteLength, sha256: hash, url }) => ({
table: path.basename(file, ".sql"),
file: path.basename(file),
...(url ? { url } : {}),
columns,
rows,
byteLength,
@@ -0,0 +1,96 @@
import assert from "node:assert/strict";
import { before, describe, it } from "node:test";
import {
creatureTemplateReference,
creatureTemplateUsesImmunitiesReference,
parseTable,
PINNED_CREATURE_IMMUNITIES_SOURCE,
} from "./import-azerothcore-world.mjs";
describe("AzerothCore creature immunity import", () => {
let creatureImmunitiesById;
let tableReport;
before(async () => {
creatureImmunitiesById = new Map();
tableReport = await parseTable(
PINNED_CREATURE_IMMUNITIES_SOURCE.fixtureRoot,
"creature_immunities",
(row) => creatureImmunitiesById.set(Number(row.ID), row),
);
});
it("uses the pinned schema, source commit, and exact table hash", () => {
assert.equal(PINNED_CREATURE_IMMUNITIES_SOURCE.commit, "4d9d1d4b5723e819c5c390f6ca1177283dbfb8e3");
assert.equal(PINNED_CREATURE_IMMUNITIES_SOURCE.path, "data/sql/base/db_world/creature_immunities.sql");
assert.equal(PINNED_CREATURE_IMMUNITIES_SOURCE.sha256,
"aee81a1d8b99986f8498a2b4c51b64eaf4b9095a0d1a12bbf3705d9588a90a13");
assert.ok(tableReport.rows > 0);
assert.equal(tableReport.sha256, PINNED_CREATURE_IMMUNITIES_SOURCE.sha256);
assert.ok([
"ID",
"SchoolMask",
"MechanicsMask",
].every((column) => tableReport.columns.includes(column)));
assert.deepEqual({
ID: creatureImmunitiesById.get(-93).ID,
SchoolMask: creatureImmunitiesById.get(-93).SchoolMask,
MechanicsMask: creatureImmunitiesById.get(-93).MechanicsMask,
}, {
ID: -93,
SchoolMask: 0,
MechanicsMask: 16_777_248,
});
});
it("resolves Lady Anacondra's immunity FK to Fear and Horror only", () => {
const lady = creatureTemplateReference({
entry: 3671,
name: "Lady Anacondra",
CreatureImmunitiesId: -93,
}, creatureImmunitiesById);
assert.equal(lady.entry, 3671);
assert.equal(lady.mechanicImmuneMask, 0x0080_0010);
assert.deepEqual(lady.mechanicImmunities, ["fear", "horror"]);
assert.equal(lady.spellSchoolImmuneMask, 0);
assert.deepEqual(lady.schoolImmunities, []);
assert.ok(!lady.mechanicImmunities.includes("silence"));
assert.ok(!lady.mechanicImmunities.includes("interrupt"));
});
it("retains source-backed Silence and Interrupt immunity on rows that declare them", () => {
const immune = creatureTemplateReference({
entry: 99_377,
name: "Source immunity control",
CreatureImmunitiesId: -377,
}, creatureImmunitiesById);
assert.ok(immune.mechanicImmunities.includes("silence"));
assert.ok(immune.mechanicImmunities.includes("interrupt"));
});
it("preserves legacy literal masks and fails closed on a missing FK", () => {
const legacy = creatureTemplateReference({
entry: 1,
mechanic_immune_mask: 0x0080_0010,
spell_school_immune_mask: 0x04,
});
assert.equal(legacy.mechanicImmuneMask, 0x0080_0010);
assert.deepEqual(legacy.mechanicImmunities, ["fear", "horror"]);
assert.equal(legacy.spellSchoolImmuneMask, 0x04);
assert.deepEqual(legacy.schoolImmunities, ["fire"]);
assert.throws(() => creatureTemplateReference({
entry: 3671,
CreatureImmunitiesId: -999_999,
}, creatureImmunitiesById), /references missing creature_immunities\.ID -999999/);
});
it("loads the FK table only for schemas that declare CreatureImmunitiesId", () => {
const legacySchema = "CREATE TABLE `creature_template` (\n `mechanic_immune_mask` int NOT NULL DEFAULT '0'\n) ENGINE=InnoDB;";
const fkSchema = "CREATE TABLE `creature_template` (\n `CreatureImmunitiesId` int NOT NULL DEFAULT '0'\n) ENGINE=InnoDB;";
assert.equal(creatureTemplateUsesImmunitiesReference(legacySchema), false);
assert.equal(creatureTemplateUsesImmunitiesReference(fkSchema), true);
});
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+893
View File
@@ -0,0 +1,893 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
// UI-driven campaign route QA. Only the isolated profile's level and RoM
// secondary class are prepared directly; dungeon entry, movement, targeting,
// combat actions, and ally selection all use the ordinary UI and keyboard.
const artifactRoot = path.resolve('playtest-artifacts/native-routes');
const stamp = new Date().toISOString().replace(/[-:.]/g, '').replace('T', '-').replace('Z', '');
const baseline = process.argv.includes('--baseline');
const outputDirectory = path.join(artifactRoot, `${baseline ? 'baseline' : 'final'}-${stamp}`);
const durationOption = process.argv.find((value) => value.startsWith('--route-seconds='));
const routeDurationMs = Math.max(15, Number(durationOption?.split('=')[1] ?? (baseline ? 25 : 90))) * 1_000;
const routeSelection = process.argv.find((value) => value.startsWith('--route='))?.split('=')[1] ?? null;
const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-native-routes-'));
const moduleFiles = fs.readdirSync('dist/assets')
.filter((file) => /^(?:index|GameRuntime|combatStore|partyStore|PartyFrames)-.*\.js$/.test(file));
const moduleUrls = moduleFiles.map((file) => `/assets/${file}`);
const routes = [
{ id: 'barren-caves', title: 'Barren Caves', category: 'RoM' },
{ id: 'wailing-caverns', title: 'Wailing Caverns', category: 'WoW' },
];
const routesToRun = routeSelection ? routes.filter((route) => route.id === routeSelection) : routes;
const actionSlots = [
['primary:face-bottom', '1'], ['primary:face-left', '2'], ['primary:face-top', '3'],
['primary:face-right', '4'], ['primary:r1', '5'], ['primary:r2', '6'],
['primary:dpad-left', '7'], ['primary:dpad-right', '8'],
];
fs.mkdirSync(outputDirectory, { recursive: true });
function distance3(left, right) {
if (!left || !right) return Number.POSITIVE_INFINITY;
return Math.hypot(left[0] - right[0], left[1] - right[1], left[2] - right[2]);
}
function distancePlanar(left, right) {
if (!left || !right) return Number.POSITIVE_INFINITY;
return Math.hypot(left[0] - right[0], left[2] - right[2]);
}
function hasFinitePosition(position) {
return Array.isArray(position) && position.length >= 3 && position.every(Number.isFinite);
}
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function flatEffects(effects = []) {
return effects.flatMap((effect) => effect.kind === 'composite' ? flatEffects(effect.effects) : [effect]);
}
function actionPlanFromSnapshot(snapshot) {
const abilities = new Map(snapshot.abilities.map((ability) => [ability.id, ability]));
const bound = actionSlots.flatMap(([bindingId, key]) => {
const id = snapshot.actionBindings[bindingId];
const ability = id ? abilities.get(id) : null;
return ability && !ability.passive && (ability.unlockLevel ?? 1) <= snapshot.player.level
? [{ key, ability, effects: flatEffects(ability.effects) }]
: [];
});
const damage = bound.filter(({ ability, effects }) => ability.target === 'hostile'
&& effects.some((effect) => effect.kind === 'damage'))
.sort((left, right) => Number(right.ability.range?.max ?? 0) - Number(left.ability.range?.max ?? 0))[0]
?? bound.find(({ effects }) => effects.some((effect) => effect.kind === 'damage'));
const healing = bound.find(({ ability, effects }) => ability.target === 'friendly'
&& effects.some((effect) => effect.kind === 'heal'));
return {
damage: damage ? { key: damage.key, id: damage.ability.id, name: damage.ability.name,
target: damage.ability.target, range: damage.ability.range ?? null,
selectionReason: 'longest-range-bound-hostile-damage-ability' } : null,
healing: healing ? { key: healing.key, id: healing.ability.id, name: healing.ability.name,
target: healing.ability.target, range: healing.ability.range ?? null } : null,
bound: bound.map(({ key, ability }) => ({ key, id: ability.id, name: ability.name,
target: ability.target, range: ability.range ?? null })),
};
}
async function readLiveState(page, includeActionSetup = false) {
return page.evaluate((includeSetup) => window.nativeRouteStores().then(({ game, combat, party,
getMobPosition, mobPositionExports, getPartyPosition, partyPositionExports }) => {
const gameState = game.getState();
const combatState = combat.getState();
return {
now: Date.now(),
game: {
mobPositionExports,
partyPositionExports,
activeDungeonId: gameState.activeDungeonId,
gameMode: gameState.gameMode,
gmTestMode: gameState.gmTestMode,
bossTest: gameState.bossTest,
assetStatus: gameState.assetStatus,
activeSpawn: gameState.activeSpawn,
playerPosition: gameState.playerPosition,
playerNavigationPosition: gameState.playerNavigationPosition,
playerGrounded: gameState.playerGrounded,
cameraYaw: gameState.cameraYaw,
areaName: gameState.areaName,
distanceTravelled: gameState.distanceTravelled,
hasMoved: gameState.hasMoved,
overlay: gameState.overlay,
dungeonIntroActive: gameState.dungeonIntroActive,
},
player: {
classId: combatState.classId,
secondaryClassId: combatState.secondaryClassId,
level: combatState.level,
role: window.nativeRouteStoresRole ?? null,
statRuleset: combatState.statRuleset,
health: combatState.health,
maxHealth: combatState.maxHealth,
resource: combatState.resource,
maxResource: combatState.maxResource,
resourceType: combatState.resourceType,
resourceName: combatState.resourceName,
position: gameState.playerPosition,
selectedTargetId: combatState.selectedTargetId,
activeCast: combatState.activeCast?.abilityId ?? null,
cooldowns: combatState.cooldowns,
},
party: party.getState().members.map((member) => ({
id: member.id,
name: member.name,
classId: member.classId,
secondaryClassId: member.secondaryClassId,
role: member.role,
statRuleset: member.statRuleset,
health: member.health,
maxHealth: member.maxHealth,
resource: member.resource,
maxResource: member.maxResource,
resourceType: member.resourceType,
status: member.status,
activeCast: member.activeCast?.abilityId ?? null,
position: getPartyPosition?.(member.id) ?? null,
})),
mobs: Object.values(combatState.mobs).filter((mob) => !mob.despawned).map((mob) => ({
id: mob.id,
name: mob.name,
level: mob.level,
health: mob.health,
maxHealth: mob.maxHealth,
dead: mob.dead,
diedAt: mob.diedAt ?? null,
engaged: mob.engaged,
position: getMobPosition?.(mob.id) ?? mob.position ?? mob.homePosition,
homePosition: mob.homePosition,
attackRevision: mob.attackRevision,
abilityCastCounts: mob.abilityCastCounts,
activeCast: mob.activeCast ? { name: mob.activeCast.name, attackId: mob.activeCast.attackId } : null,
boss: mob.boss,
})),
...(includeSetup ? { abilities: combatState.abilities, actionBindings: combatState.actionBindings } : {}),
feedback: combatState.feedback ? { kind: combatState.feedback.kind,
message: combatState.feedback.message, abilityId: combatState.feedback.abilityId ?? null,
targetId: combatState.feedback.targetId ?? null } : null,
events: combatState.combatEvents.slice(-200),
};
}), includeActionSetup);
}
function liveMobs(snapshot) {
return snapshot.mobs.filter((mob) => !mob.dead && !mob.despawned);
}
function sourcePlayerEvent(event) {
return event.sourceActorId === '__player__';
}
async function selectHostileWithTab(page, routeResult, attempts = 10) {
for (let attempt = 0; attempt < attempts; attempt += 1) {
await page.keyboard.press('Tab');
await page.waitForTimeout(80);
const snapshot = await readLiveState(page);
const target = snapshot.mobs.find((mob) => mob.id === snapshot.player.selectedTargetId
&& !mob.dead && !mob.despawned && !mob.boss && hasFinitePosition(mob.position));
routeResult.targeting.push({ at: snapshot.now, input: 'Tab', selectedTargetId: snapshot.player.selectedTargetId,
targetName: target?.name ?? null, targetDistance: target ? distance3(snapshot.player.position, target.position) : null });
if (target) return { snapshot, target };
}
return null;
}
function movementKeysTo(from, to, yaw) {
const dx = to[0] - from[0];
const dz = to[2] - from[2];
const magnitude = Math.hypot(dx, dz) || 1;
const worldX = dx / magnitude;
const worldZ = dz / magnitude;
const cosYaw = Math.cos(yaw);
const sinYaw = Math.sin(yaw);
const localX = -worldX * cosYaw + worldZ * sinYaw;
const localForward = worldX * sinYaw + worldZ * cosYaw;
const keys = [];
if (localForward > 0.32) keys.push('w');
else if (localForward < -0.32) keys.push('s');
if (localX > 0.32) keys.push('d');
else if (localX < -0.32) keys.push('a');
return keys;
}
async function walkToward(page, routeResult, destination, durationMs = 650) {
const before = await readLiveState(page);
const keys = movementKeysTo(before.player.position, destination, before.game.cameraYaw);
if (!keys.length) return null;
for (const key of keys) await page.keyboard.down(key);
try {
if (routeSelection && routesToRun.length !== 1) throw new Error(`Unknown native campaign route id: ${routeSelection}`);
await page.waitForTimeout(durationMs);
} finally {
for (const key of keys) await page.keyboard.up(key);
}
await page.waitForTimeout(150);
const after = await readLiveState(page);
const step = {
at: after.now,
keys: keys.map((key) => key.toUpperCase()),
durationMs,
from: before.player.position,
to: after.player.position,
destination,
planarDisplacement: distancePlanar(before.player.position, after.player.position),
spatialDisplacement: distance3(before.player.position, after.player.position),
gameDistanceTravelledDelta: after.game.distanceTravelled - before.game.distanceTravelled,
targetDistanceBefore: distance3(before.player.position, destination),
targetDistanceAfter: distance3(after.player.position, destination),
blocked: distancePlanar(before.player.position, after.player.position) < 0.08,
};
routeResult.movement.push(step);
return { snapshot: after, step };
}
async function performCast(page, routeResult, action, kind, intendedTargetId, reason) {
const before = await readLiveState(page);
const since = before.now - 1;
await page.keyboard.press(action.key);
const castWaitStarted = Date.now();
let after = await readLiveState(page);
const relevantEventsFor = (state) => state.events.filter((event) => event.occurredAt >= since
&& event.abilityId === action.id && sourcePlayerEvent(event)
&& (kind === 'damage' ? event.kind === 'damage' : event.kind === 'healing'));
while (Date.now() - castWaitStarted < 2_600) {
const relevant = relevantEventsFor(after);
if (relevant.some((event) => event.effectiveAmount > 0)
|| (!after.player.activeCast && Date.now() - castWaitStarted >= 650)) break;
await page.waitForTimeout(150);
after = await readLiveState(page);
}
const relevantEvents = relevantEventsFor(after);
const memberBefore = before.party.find((member) => member.id === intendedTargetId);
const memberAfter = after.party.find((member) => member.id === intendedTargetId);
const cast = {
at: before.now,
key: action.key,
abilityId: action.id,
abilityName: action.name,
kind,
reason,
intendedTargetId,
selectedTargetIdBefore: before.player.selectedTargetId,
targetHealthBefore: kind === 'healing'
? before.party.find((member) => member.id === intendedTargetId)?.health ?? null
: before.mobs.find((mob) => mob.id === intendedTargetId)?.health ?? null,
targetHealthAfter: kind === 'healing'
? after.party.find((member) => member.id === intendedTargetId)?.health ?? null
: after.mobs.find((mob) => mob.id === intendedTargetId)?.health ?? null,
playerPositionAtInput: before.player.position,
allyPositionAtInput: memberBefore?.position ?? null,
allyDistanceAtInput: memberBefore?.position
? distance3(before.player.position, memberBefore.position) : null,
allyDistanceAfterCast: memberAfter?.position
? distance3(after.player.position, memberAfter.position) : null,
castWaitMs: Date.now() - castWaitStarted,
resourceBefore: before.player.resource,
resourceAfter: after.player.resource,
resolvedEvents: relevantEvents.map((event) => ({ kind: event.kind, targetActorId: event.targetActorId,
effectiveAmount: event.effectiveAmount, overheal: event.overheal, school: event.school ?? null,
abilityId: event.abilityId, occurredAt: event.occurredAt })),
actionResolved: relevantEvents.some((event) => event.effectiveAmount > 0),
feedback: after.feedback,
};
routeResult.actions.push(cast);
return { before, after, cast };
}
async function selectPartyMember(page, member) {
const label = new RegExp(`^Target ${escapeRegex(member.name)},`);
// Accessible target labels are unique for native companions; getByRole keeps
// the test on the same clickable party-frame that a player would use.
await page.getByRole('button', { name: label }).click();
}
async function rosterContinueHitTest(page, viewport) {
return page.evaluate((viewportSize) => {
const button = document.querySelector('.roster-enter');
const toggle = document.querySelector('.single-context-toggle');
if (!button) throw new Error('Roster Continue button is missing.');
const buttonRect = button.getBoundingClientRect();
const toggleRect = toggle?.getBoundingClientRect() ?? null;
const overlaps = Boolean(toggleRect
&& buttonRect.left < toggleRect.right && buttonRect.right > toggleRect.left
&& buttonRect.top < toggleRect.bottom && buttonRect.bottom > toggleRect.top);
const center = { x: buttonRect.left + buttonRect.width / 2, y: buttonRect.top + buttonRect.height / 2 };
const hit = document.elementFromPoint(center.x, center.y);
return {
viewport: viewportSize,
buttonRect: { x: buttonRect.x, y: buttonRect.y, width: buttonRect.width, height: buttonRect.height },
toggleRect: toggleRect ? { x: toggleRect.x, y: toggleRect.y,
width: toggleRect.width, height: toggleRect.height } : null,
overlaps,
centerHit: hit?.className?.toString?.() ?? hit?.tagName ?? null,
centerHitIsContinue: Boolean(hit && (hit === button || button.contains(hit))),
};
}, viewport);
}
async function prepareCharacter(page) {
await page.getByRole('button', { name: /Play offline/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
await page.locator('.class-option').filter({ hasText: /^Priest/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('RoutePriestQA');
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.character-select-screen').waitFor();
const profile = await page.evaluate(async () => {
const { shell } = await window.nativeRouteStores();
const state = shell.getState();
const character = state.characters.find((entry) => entry.id === state.selectedCharacterId);
if (!character || character.classId !== 'rom-priest') throw new Error('UI-created RoM Priest profile is missing.');
shell.setState({ activeCharacter: character });
shell.getState().saveActiveProgression(30, 0, character.talentRanks);
shell.setState({ activeCharacter: null });
if (!shell.getState().chooseRomSecondaryClass('rom-knight')) throw new Error('Priest/Knight pairing was not accepted.');
const updated = shell.getState().characters.find((entry) => entry.id === character.id);
return { id: updated.id, name: updated.name, classId: updated.classId,
secondaryClassId: updated.secondaryClassId, level: updated.level, categoryId: updated.categoryId };
});
assert.equal(profile.classId, 'rom-priest');
assert.equal(profile.secondaryClassId, 'rom-knight');
assert.equal(profile.level, 30);
await page.waitForTimeout(1_500);
await page.screenshot({ path: path.join(outputDirectory, 'roster-priest-knight-level-30.png') });
const desktopRosterHit = await rosterContinueHitTest(page, { width: 1440, height: 900 });
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(300);
await page.screenshot({ path: path.join(outputDirectory, 'roster-compact-priest-knight-level-30.png') });
const compactRosterHit = await rosterContinueHitTest(page, { width: 390, height: 844 });
await page.setViewportSize({ width: 1440, height: 900 });
await page.waitForTimeout(300);
const rosterUi = { desktop: desktopRosterHit, compact: compactRosterHit,
forceClickUsed: false };
if (!baseline) {
assert.equal(desktopRosterHit.overlaps, false, 'Desktop roster toggle does not overlap Continue.');
assert.equal(desktopRosterHit.centerHitIsContinue, true, 'Desktop Continue button owns its center hit point.');
assert.equal(compactRosterHit.overlaps, false, 'Compact roster toggle does not overlap Continue.');
assert.equal(compactRosterHit.centerHitIsContinue, true, 'Compact Continue button owns its center hit point.');
}
if (desktopRosterHit.centerHitIsContinue) {
await page.locator('.roster-enter').click();
} else if (baseline) {
// A baseline with the known floating-toggle obstruction may continue via
// a forced pointer action for diagnostics, and records that exception.
rosterUi.forceClickUsed = true;
await page.locator('.roster-enter').click({ force: true });
} else {
throw new Error('Final-build roster Continue button is obscured at its center hit point.');
}
await page.locator('.main-menu-screen').waitFor();
return { profile, rosterUi };
}
async function enterCampaignDungeon(page, route, isFirst, minimumLevel = 30) {
if (!isFirst) {
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Main menu', exact: true }).waitFor({ state: 'visible' });
await page.getByRole('button', { name: 'Main menu', exact: true }).click();
await page.locator('.main-menu-screen').waitFor();
}
await page.getByRole('button', { name: /^Dungeons/ }).click();
await page.locator('.dungeon-select-screen').waitFor();
await page.locator('.dungeon-category-tab').filter({ hasText: route.category }).click();
const exactTitle = new RegExp(`^${escapeRegex(route.title)}$`, 'i');
const row = page.locator('.dungeon-select-row').filter({
has: page.locator('strong').filter({ hasText: exactTitle }),
});
const exactRowCount = await row.count();
assert.equal(exactRowCount, 1, `${route.title} has one exact source campaign row in ${route.category}`);
await row.waitFor({ state: 'visible' });
const selectedRowTitle = (await row.locator('strong').innerText()).trim();
assert.equal(selectedRowTitle, route.title, `${route.title} campaign selection is exact`);
await page.screenshot({ path: path.join(outputDirectory, `${route.id}-campaign-picker.png`) });
await row.click();
if (await page.getByRole('alertdialog').count()) {
const confirmDownload = page.getByRole('button', { name: 'Download and continue', exact: true });
if (await confirmDownload.count()) await confirmDownload.click();
} else {
await page.locator('.dungeon-select-enter').click();
if (await page.getByRole('alertdialog').count()) {
await page.getByRole('button', { name: 'Download and continue', exact: true }).click();
}
}
await page.locator('.dungeon-role-option--healer').waitFor({ state: 'visible', timeout: 180_000 });
await page.locator('.dungeon-role-option--healer').click();
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 180_000 }).catch(() => {});
await page.waitForFunction(async (expectedId) => {
const { game } = await window.nativeRouteStores();
const state = game.getState();
return state.activeDungeonId === expectedId && state.assetStatus === 'ready';
}, route.id, { timeout: 240_000 });
await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached', timeout: 15_000 }).catch(() => {});
await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur());
const state = await readLiveState(page);
assert.equal(state.game.activeDungeonId, route.id, `${route.title} entered via the selected campaign route`);
assert.equal(state.game.gameMode, 'dungeon');
assert.equal(state.game.gmTestMode, false, 'campaign entry is not GM mode');
assert.equal(state.game.bossTest, null, 'campaign entry is not Boss Testing');
assert.equal(state.player.classId, 'rom-priest');
assert.equal(state.player.secondaryClassId, 'rom-knight');
assert(state.player.level >= minimumLevel,
`${route.title} entry level remains at least the UI-created profile's level ${minimumLevel}`);
assert.equal(state.party.length, 4, 'the UI-created Priest has four NPC companions');
assert(state.party.every((member) => member.classId.startsWith('rom-') && member.statRuleset === 'rom'),
'all four companions use native RoM identities and rules');
await page.screenshot({ path: path.join(outputDirectory, `${route.id}-entrance.png`) });
return state;
}
async function runRouteSegment(page, route, entryState, profile) {
const result = {
dungeonId: route.id,
title: route.title,
coverage: 'campaign entrance through first live in-scene pack/encounter segment; no full route or boss-clear claim',
enteredThrough: 'normal Dungeons picker and healer role selection',
bossTesting: false,
profile,
playerAtEntry: entryState.player,
partyAtEntry: entryState.party,
dungeonEntry: entryState.game,
startingPosition: entryState.player.position,
actionPlan: actionPlanFromSnapshot(entryState),
movement: [],
healPursuits: [],
targeting: [],
actions: [],
samples: [],
observedCombatEvents: [],
screenshots: {
entrance: `${route.id}-entrance.png`,
campaignPicker: `${route.id}-campaign-picker.png`,
walk: `${route.id}-keyboard-traversal.png`,
middle: `${route.id}-encounter-midpoint.png`,
endpoint: `${route.id}-segment-end.png`,
},
segmentStartedAt: Date.now(),
};
const seenEventKeys = new Set(entryState.events.map((event) => [event.occurredAt, event.sourceActorId,
event.targetActorId, event.abilityId, event.kind, event.effectiveAmount].join('|')));
const observedDeadMobs = new Map();
const collectCombatEvents = (snapshot) => {
for (const mob of snapshot.mobs.filter((entry) => entry.dead)) observedDeadMobs.set(mob.id, mob.name);
for (const event of snapshot.events) {
const key = [event.occurredAt, event.sourceActorId, event.targetActorId,
event.abilityId, event.kind, event.effectiveAmount].join('|');
if (seenEventKeys.has(key)) continue;
seenEventKeys.add(key);
result.observedCombatEvents.push(event);
}
};
const initialMobs = liveMobs(entryState).map((mob) => ({
id: mob.id, name: mob.name, level: mob.level, position: mob.position,
distanceFromEntrance: distance3(entryState.player.position, mob.position),
boss: mob.boss,
})).sort((left, right) => left.distanceFromEntrance - right.distanceFromEntrance);
result.sceneMobsAtEntry = initialMobs;
const plan = result.actionPlan;
if (!plan.damage) {
result.bindingDiagnostic = {
actionBindings: entryState.actionBindings,
computedActionPlan: plan,
playerLevel: entryState.player.level,
boundSlots: actionSlots.map(([bindingId, key]) => ({ bindingId, key,
abilityId: entryState.actionBindings[bindingId] ?? null })),
rawBoundAbilities: actionSlots.map(([bindingId, key]) => {
const id = entryState.actionBindings[bindingId];
const ability = entryState.abilities.find((candidate) => candidate.id === id);
return { key, id, passive: ability?.passive, unlockLevel: ability?.unlockLevel,
target: ability?.target, rawEffects: ability?.effects,
flattenedEffects: ability ? flatEffects(ability.effects).map((effect) => effect.kind) : null };
}),
abilityCount: entryState.abilities.length,
availableAbilities: entryState.abilities.filter((ability) => !ability.passive
&& (ability.unlockLevel ?? 1) <= entryState.player.level).map((ability) => ({ id: ability.id,
name: ability.name, target: ability.target, unlockLevel: ability.unlockLevel,
effects: flatEffects(ability.effects).map((effect) => effect.kind) })),
};
fs.writeFileSync(path.join(outputDirectory, `${route.id}-binding-diagnostic.json`),
`${JSON.stringify(result.bindingDiagnostic, null, 2)}\n`);
throw new Error(`${route.title}: no native damage ability is bound to a normal keyboard slot. Details: ${JSON.stringify({
actionBindings: result.bindingDiagnostic.actionBindings,
availableAbilities: result.bindingDiagnostic.availableAbilities.slice(0, 18),
})}`);
}
result.mappedHealAvailable = Boolean(plan.healing);
let snapshot = await readLiveState(page);
let selected = await selectHostileWithTab(page, result, 10);
if (selected) snapshot = selected.snapshot;
let candidate = (selected?.target && hasFinitePosition(selected.target.position) ? selected.target : null)
?? liveMobs(snapshot)
.map((mob) => ({ ...mob, distance: distance3(snapshot.player.position, mob.position) }))
.filter((mob) => mob.distance < 120 && !mob.boss && hasFinitePosition(mob.position))
.sort((left, right) => left.distance - right.distance)[0]
?? null;
if (!candidate) {
const diagnostic = await readLiveState(page, true);
fs.writeFileSync(path.join(outputDirectory, `${route.id}-target-diagnostic.json`),
`${JSON.stringify({ targeting: result.targeting, sceneMobsAtEntry: result.sceneMobsAtEntry,
player: diagnostic.player, mobs: diagnostic.mobs }, null, 2)}\n`);
throw new Error(`${route.title}: no live in-scene pack was available near the campaign entrance.`);
}
assert(hasFinitePosition(candidate.position), `${route.title}: candidate mob transform must be finite before movement.`);
result.firstPackTarget = { id: candidate.id, name: candidate.name,
position: candidate.position, transformAvailable: hasFinitePosition(candidate.position),
distanceAtSelection: distance3(snapshot.player.position, candidate.position), boss: candidate.boss };
const damageRange = Number(plan.damage.range?.max ?? 40);
let walked = 0;
let stuckSteps = 0;
for (let step = 0; step < 18; step += 1) {
const current = await readLiveState(page);
let freshTarget = current.mobs.find((mob) => mob.id === candidate.id && !mob.dead
&& !mob.despawned && !mob.boss && hasFinitePosition(mob.position));
if (!freshTarget) {
freshTarget = liveMobs(current).filter((mob) => !mob.boss && hasFinitePosition(mob.position))
.map((mob) => ({ ...mob, distance: distance3(current.player.position, mob.position) }))
.filter((mob) => mob.distance < 120).sort((left, right) => left.distance - right.distance)[0];
}
if (!freshTarget) break;
candidate = freshTarget;
const targetPosition = freshTarget.position;
const dist = distance3(current.player.position, targetPosition);
if (dist <= Math.max(7, damageRange - 3) && walked >= 6) break;
const moved = await walkToward(page, result, targetPosition, 700);
if (!moved) break;
walked += moved.step.planarDisplacement;
stuckSteps = moved.step.blocked ? stuckSteps + 1 : 0;
snapshot = moved.snapshot;
if (stuckSteps >= 3) break;
}
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.walk) });
result.keyboardTravelDistance = walked;
result.positionAfterTraversal = snapshot.player.position;
result.distanceTravelledAtEntry = entryState.game.distanceTravelled;
result.distanceTravelledAfterTraversal = snapshot.game.distanceTravelled;
await page.keyboard.press('F1');
result.partyCommands = [{ at: Date.now(), key: 'F1', command: 'Attack', targetId: snapshot.player.selectedTargetId }];
await page.waitForTimeout(900);
await page.keyboard.press('F2');
result.partyCommands.push({ at: Date.now(), key: 'F2', command: 'Defend', reason: 'hold the initial encounter near the healer' });
const targetAgain = await selectHostileWithTab(page, result, 6);
if (targetAgain) {
snapshot = targetAgain.snapshot;
if (targetAgain.target.position) candidate = targetAgain.target;
}
const endAt = Date.now() + routeDurationMs;
let lastDamageAt = 0;
let lastHealAt = 0;
let middleCaptured = false;
let lastSampleAt = 0;
let noTargetStreak = 0;
while (Date.now() < endAt) {
snapshot = await readLiveState(page);
collectCombatEvents(snapshot);
if (snapshot.player.health <= 0) break;
if (lastSampleAt === 0 || snapshot.now - lastSampleAt >= 1_000) {
result.samples.push({ at: snapshot.now, player: snapshot.player,
party: snapshot.party, mobs: snapshot.mobs.map((mob) => ({ id: mob.id, name: mob.name,
health: mob.health, maxHealth: mob.maxHealth, dead: mob.dead, engaged: mob.engaged,
activeCast: mob.activeCast, position: mob.position })) });
lastSampleAt = snapshot.now;
}
const currentTarget = snapshot.mobs.find((mob) => mob.id === snapshot.player.selectedTargetId
&& !mob.dead && !mob.despawned && !mob.boss && hasFinitePosition(mob.position));
const wounded = snapshot.party.filter((member) => member.health > 0 && member.health < member.maxHealth - 1)
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth);
if (wounded.length && plan.healing && Date.now() - lastHealAt > 1_100) {
let member = wounded[0];
const healRange = Number(plan.healing.range?.max ?? 25);
const pursuit = { at: Date.now(), memberId: member.id, memberName: member.name,
startPosition: snapshot.player.position, startAllyPosition: member.position,
startDistance: hasFinitePosition(member.position)
? distance3(snapshot.player.position, member.position) : null,
movement: [], outcome: 'position-unavailable' };
for (let step = 0; step < 6 && !snapshot.player.activeCast; step += 1) {
const refreshed = await readLiveState(page);
const freshMember = refreshed.party.find((entry) => entry.id === member.id);
if (!freshMember || freshMember.health <= 0 || !hasFinitePosition(freshMember.position)) {
snapshot = refreshed;
pursuit.outcome = 'member-no-longer-live-or-position-unavailable';
break;
}
snapshot = refreshed;
member = freshMember;
const distance = distance3(snapshot.player.position, member.position);
pursuit.lastAllyPosition = member.position;
pursuit.lastDistance = distance;
if (distance <= healRange - 1.5) {
pursuit.outcome = 'within-heal-range';
break;
}
const moved = await walkToward(page, result, member.position, 600);
if (!moved || moved.step.planarDisplacement < 0.08) {
pursuit.outcome = 'movement-blocked-or-aligned';
break;
}
pursuit.movement.push(moved.step);
snapshot = moved.snapshot;
}
snapshot = await readLiveState(page);
member = snapshot.party.find((entry) => entry.id === member.id) ?? member;
pursuit.endPosition = snapshot.player.position;
pursuit.endAllyPosition = member.position ?? null;
pursuit.endDistance = hasFinitePosition(member.position)
? distance3(snapshot.player.position, member.position) : null;
if (pursuit.endDistance !== null && pursuit.endDistance <= healRange - 1.5) {
pursuit.outcome = 'within-heal-range';
}
if (pursuit.outcome !== 'within-heal-range') {
result.healPursuits.push(pursuit);
lastHealAt = Date.now();
await page.waitForTimeout(200);
continue;
}
const targetBefore = snapshot.player.selectedTargetId;
await selectPartyMember(page, member);
const cast = await performCast(page, result, plan.healing, 'healing', member.id,
`heal-lowest-wounded-active-NPC (${Math.round(member.health / member.maxHealth * 100)}% before cast)`);
pursuit.castActionIndex = result.actions.length - 1;
pursuit.outcome = cast.cast.actionResolved ? 'cast-effective-heal'
: cast.cast.actionAccepted ? 'cast-accepted-without-observed-healing' : 'cast-not-accepted';
result.healPursuits.push(pursuit);
lastHealAt = Date.now();
if (!middleCaptured) {
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.middle) });
middleCaptured = true;
}
result.actions[result.actions.length - 1].hostileTargetBeforeAllySelection = targetBefore;
if (cast.cast.actionResolved) result.effectiveNativeHeals = (result.effectiveNativeHeals ?? 0) + 1;
noTargetStreak = 0;
continue;
}
let target = currentTarget;
if (!target) {
const selectedTarget = await selectHostileWithTab(page, result, 6);
target = selectedTarget?.target ?? null;
if (selectedTarget) snapshot = selectedTarget.snapshot;
}
if (!target) {
noTargetStreak += 1;
const available = liveMobs(snapshot).filter((mob) => !mob.boss).map((mob) => ({ ...mob,
distance: distance3(snapshot.player.position, mob.position) }))
.filter((mob) => mob.distance < 75 && hasFinitePosition(mob.position))
.sort((left, right) => left.distance - right.distance)[0];
if (available && noTargetStreak < 3) {
const moved = await walkToward(page, result, available.position, 650);
if (moved && moved.step.planarDisplacement > 0.1) {
await selectHostileWithTab(page, result, 6);
continue;
}
}
await page.waitForTimeout(350);
continue;
}
noTargetStreak = 0;
const targetDistance = distance3(snapshot.player.position, target.position);
const targetRange = Math.max(0, Number(plan.damage.range?.max ?? 40) - 1.5);
if (targetDistance > targetRange || !target.position) {
const destination = target.position ?? (target.id === candidate.id ? candidate.position : null);
if (!destination) {
result.targetingWithoutPosition ??= [];
result.targetingWithoutPosition.push({ at: snapshot.now, id: target.id, name: target.name });
const cast = await performCast(page, result, plan.damage, 'damage', target.id,
'keyboard damage attempt after Tab selection where mob transform was not readable');
if (cast.cast.actionResolved) result.effectivePlayerDamageCasts = (result.effectivePlayerDamageCasts ?? 0) + 1;
continue;
}
const moved = await walkToward(page, result, destination, 550);
if (moved && moved.step.planarDisplacement > 0.08) continue;
}
if (Date.now() - lastDamageAt > 700) {
const cast = await performCast(page, result, plan.damage, 'damage', target.id,
'player keyboard cast against the Tab-selected live campaign mob');
lastDamageAt = Date.now();
if (!middleCaptured) {
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.middle) });
middleCaptured = true;
}
if (cast.cast.actionResolved) result.effectivePlayerDamageCasts = (result.effectivePlayerDamageCasts ?? 0) + 1;
} else {
await page.waitForTimeout(250);
}
}
const final = await readLiveState(page);
collectCombatEvents(final);
result.segmentEndedAt = Date.now();
result.endingPosition = final.player.position;
result.endingDistanceTravelled = final.game.distanceTravelled;
result.playerAtEndpoint = final.player;
result.partyAtEndpoint = final.party;
result.sceneMobsAtEndpoint = final.mobs;
const playerEvents = result.observedCombatEvents.filter(sourcePlayerEvent);
const healedPartyIds = new Set(entryState.party.map((member) => member.id));
const effectiveHealEvents = playerEvents.filter((event) => event.kind === 'healing'
&& healedPartyIds.has(event.targetActorId) && event.effectiveAmount > 0);
const effectiveDamageEvents = playerEvents.filter((event) => event.kind === 'damage' && event.effectiveAmount > 0);
for (let index = 0; index < result.actions.length; index += 1) {
const action = result.actions[index];
const nextSameActionAt = result.actions.slice(index + 1)
.find((next) => next.abilityId === action.abilityId)?.at ?? Number.POSITIVE_INFINITY;
const actionEvents = playerEvents.filter((event) => event.abilityId === action.abilityId
&& event.targetActorId === action.intendedTargetId
&& event.occurredAt >= action.at - 20 && event.occurredAt < nextSameActionAt);
const effectEvents = actionEvents.filter((event) => event.kind === action.kind);
action.resolvedEvents = effectEvents.map((event) => ({ kind: event.kind,
targetActorId: event.targetActorId, effectiveAmount: event.effectiveAmount,
overheal: event.overheal, school: event.school ?? null, abilityId: event.abilityId,
occurredAt: event.occurredAt }));
action.actionAccepted = actionEvents.some((event) => event.kind === 'cast'
&& ['started', 'completed'].includes(event.outcome));
action.actionResolved = effectEvents.some((event) => event.effectiveAmount > 0);
}
result.effectiveNativeHeals = effectiveHealEvents.length;
result.totalEffectiveNativeHealing = effectiveHealEvents.reduce((sum, event) => sum + event.effectiveAmount, 0);
result.effectivePlayerDamageEvents = effectiveDamageEvents.length;
result.totalEffectivePlayerDamage = effectiveDamageEvents.reduce((sum, event) => sum + event.effectiveAmount, 0);
result.coverageEndpoint = {
endpoint: final.game.areaName,
endpointPosition: final.player.position,
accumulatedTravelDistance: final.game.distanceTravelled - entryState.game.distanceTravelled,
keyboardStepDisplacement: result.movement.reduce((sum, step) => sum + step.planarDisplacement, 0),
totalKeyboardKeyDowns: result.movement.length,
damageEvents: result.effectivePlayerDamageEvents,
totalEffectivePlayerDamage: result.totalEffectivePlayerDamage,
healingEvents: effectiveHealEvents.length,
totalEffectiveNativeHealing: result.totalEffectiveNativeHealing,
observedSchools: [...new Set(result.observedCombatEvents.map((event) => event.school).filter(Boolean))].sort(),
eventCountsByKind: Object.fromEntries([...new Set(result.observedCombatEvents.map((event) => event.kind))]
.map((kind) => [kind, result.observedCombatEvents.filter((event) => event.kind === kind).length])),
killedMobNames: final.mobs.filter((mob) => mob.dead).map((mob) => mob.name),
observedDeadMobNames: [...new Set(observedDeadMobs.values())].sort(),
observedDeadMobCount: observedDeadMobs.size,
partyDeaths: final.party.filter((member) => member.health <= 0).map((member) => member.name),
};
await page.screenshot({ path: path.join(outputDirectory, result.screenshots.endpoint) });
result.pageErrors = [];
fs.writeFileSync(path.join(outputDirectory, `${route.id}-route.json`), `${JSON.stringify(result, null, 2)}\n`);
return result;
}
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir: dataDirectory, enableShelvedModes: false });
await new Promise((resolve, reject) => {
runtime.server.once('error', reject);
runtime.server.listen(0, '127.0.0.1', resolve);
});
const address = runtime.server.address();
const baseUrl = `http://127.0.0.1:${address.port}`;
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.setDefaultTimeout(120_000);
const pageErrors = [];
const consoleErrors = [];
const completedRoutes = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() });
});
await page.addInitScript(({ urls }) => {
window.nativeRouteKeyLog = [];
window.addEventListener('keydown', (event) => {
if (['KeyW', 'KeyA', 'KeyS', 'KeyD', 'Tab', 'F1', 'Digit1', 'Digit2', 'Digit3', 'Digit4',
'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Escape'].includes(event.code)) {
window.nativeRouteKeyLog.push({ at: Date.now(), code: event.code, repeat: event.repeat });
}
}, true);
window.nativeRouteStores = async () => {
const modules = await Promise.all(urls.map((url) => import(url)));
const namedExports = modules.flatMap((module) => Object.entries(module));
const exports = namedExports.map(([, value]) => value);
const stores = exports.filter((value) => value && typeof value.getState === 'function');
// Vite renames this exported registry function. Match the inspected pure
// getMobRuntimeTransform body: it only reads the private Map and copies
// position/yaw, returning null for an unmounted actor.
const transformExport = namedExports.find(([, value]) => {
if (typeof value !== 'function') return false;
const source = Function.prototype.toString.call(value);
return source.includes('.mounted') && source.includes('.value')
&& source.includes('position:') && source.includes('yaw:')
&& source.includes('...') && source.includes('null');
});
const readTransform = transformExport?.[1] ?? null;
// Source-mapped to getPartyRuntimePosition: it reads the party position
// registry and returns a defensive copy or null, with no state mutation.
const partyPositionExport = namedExports.find(([, value]) => {
if (typeof value !== 'function') return false;
const source = Function.prototype.toString.call(value);
return /\.get\(/.test(source)
&& /return\s+[A-Za-z_$][\w$]*\s*\?\s*\[\s*\.\.\.\s*[A-Za-z_$][\w$]*\s*\]\s*:\s*null/.test(source);
});
const readPartyPosition = partyPositionExport?.[1] ?? null;
return {
getMobPosition: readTransform ? (id) => readTransform(id)?.position ?? null : null,
mobPositionExports: transformExport ? [transformExport[0]] : [],
getPartyPosition: readPartyPosition,
partyPositionExports: partyPositionExport ? [partyPositionExport[0]] : [],
game: stores.find((store) => typeof store.getState()?.relocatePlayer === 'function'),
shell: stores.find((store) => typeof store.getState()?.saveActiveProgression === 'function'),
combat: stores.find((store) => typeof store.getState()?.castAbility === 'function'),
party: stores.find((store) => typeof store.getState()?.setCommand === 'function'
&& Array.isArray(store.getState()?.members)),
};
};
}, { urls: moduleUrls });
try {
if (moduleFiles.length < 3) throw new Error(`Expected built game assets were not found in dist/assets: ${moduleFiles.join(', ')}`);
await page.goto(baseUrl, { waitUntil: 'domcontentloaded' });
const { profile, rosterUi } = await prepareCharacter(page);
for (let index = 0; index < routesToRun.length; index += 1) {
const route = routesToRun[index];
await enterCampaignDungeon(page, route, index === 0, profile.level);
await page.evaluate(() => { window.nativeRouteStoresRole = 'healer'; });
const entry = await readLiveState(page, true);
const routeResult = await runRouteSegment(page, route, entry, profile);
routeResult.pageErrors = pageErrors.slice();
routeResult.consoleErrors = consoleErrors.slice();
routeResult.keyboardEvents = await page.evaluate(() => window.nativeRouteKeyLog.slice(-500));
fs.writeFileSync(path.join(outputDirectory, `${route.id}-route.json`), `${JSON.stringify(routeResult, null, 2)}\n`);
completedRoutes.push(routeResult);
}
assert.deepEqual(pageErrors, [], `No browser page exceptions: ${JSON.stringify(pageErrors)}`);
fs.writeFileSync(path.join(outputDirectory, 'results.json'), `${JSON.stringify({
status: 'complete',
snapshotKind: baseline ? 'baseline-existing-dist; final-build rerun required' : 'final-build route QA',
buildAssets: moduleFiles,
routeDurationMs,
profile,
rosterUi,
routes: completedRoutes.map((route) => ({
dungeonId: route.dungeonId,
coverage: route.coverage,
coverageEndpoint: route.coverageEndpoint,
mappedHealAvailable: route.mappedHealAvailable,
effectiveNativeHeals: route.effectiveNativeHeals ?? 0,
totalEffectiveNativeHealing: route.totalEffectiveNativeHealing ?? 0,
effectivePlayerDamageEvents: route.effectivePlayerDamageEvents ?? 0,
totalEffectivePlayerDamage: route.totalEffectivePlayerDamage ?? 0,
coverageEndpoint: route.coverageEndpoint,
})),
pageErrors,
consoleErrors,
}, null, 2)}\n`);
console.log(`Native campaign route QA ${baseline ? 'baseline' : 'complete'}: ${outputDirectory}`);
} catch (error) {
await page.screenshot({ path: path.join(outputDirectory, 'failure.png') }).catch(() => {});
const failed = { status: 'failed', snapshotKind: baseline ? 'baseline-existing-dist' : 'final-build route QA',
buildAssets: moduleFiles, error: error.stack ?? error.message, pageErrors, consoleErrors,
bodyText: await page.locator('body').innerText().catch(() => '') };
fs.writeFileSync(path.join(outputDirectory, 'failure.json'), `${JSON.stringify(failed, null, 2)}\n`);
console.error(failed.bodyText.slice(-2_500));
throw error;
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
const target = fs.realpathSync(dataDirectory);
if (path.dirname(target) !== fs.realpathSync(os.tmpdir())
|| !path.basename(target).startsWith('healer-man-native-routes-')) {
throw new Error('Unsafe temporary playtest database cleanup path.');
}
fs.rmSync(target, { recursive: true, force: true });
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+116
View File
@@ -0,0 +1,116 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const output = path.resolve('playtest-artifacts/party-equipment');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-equipment-'));
fs.mkdirSync(output, { recursive: true });
async function capture(page, name) {
await page.screenshot({ path: path.join(output, `${name}.png`) });
}
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir, staticDir: path.resolve('dist'), contentDir: path.resolve('content'), enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(0, '127.0.0.1', resolve));
console.log({ cwd: process.cwd(), staticDir: runtime.staticDir, indexExists: fs.existsSync(path.join(runtime.staticDir, 'index.html')), port: runtime.server.address().port });
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const results = {};
try {
for (const category of process.argv.includes('--rom-only') ? ['RoM'] : ['WoW', 'RoM']) {
const context = await browser.newContext({ viewport: { width: 1600, height: 1000 } });
const page = await context.newPage();
page.setDefaultTimeout(60000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error' && !message.text().startsWith('Failed to load resource:')) errors.push(message.text());
});
page.on('response', (response) => {
if (response.status() >= 400 && !response.url().endsWith('/favicon.ico')) errors.push(`${response.status()}: ${response.url()}`);
});
try {
await page.goto(`http://127.0.0.1:${runtime.server.address().port}`, { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: /Play offline/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.creator-category-tabs button').filter({ hasText: category }).click();
await page.locator('.class-option').filter({ hasText: 'Priest' }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name', { exact: true }).fill(category === 'WoW' ? 'Wowgear' : 'Romgear');
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.character-select-screen').waitFor();
await page.waitForTimeout(4000);
await capture(page, `${category}-roster`);
await page.getByRole('button', { name: /Continue as/ }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
await page.getByRole('button', { name: /Downloads/ }).click();
const pack = page.locator('.content-pack-row').filter({ hasText: category === 'WoW'
? 'Extra Wrath Character Models & Skins' : 'RuneWaker Character Models & Skins' });
await pack.getByRole('button', { name: 'Download', exact: true }).click();
await pack.getByText('Installed — offline ready', { exact: true }).waitFor({ timeout: 180000 });
await page.getByRole('button', { name: 'Back', exact: true }).click();
await page.getByRole('button', { name: 'Change character', exact: true }).click();
await page.locator('.character-select-screen').waitFor();
await page.waitForTimeout(5000);
await capture(page, `${category}-roster`);
await page.getByRole('button', { name: /Continue as/ }).click({ position: { x: 24, y: 30 } });
await page.locator('.main-menu-screen').waitFor();
await page.getByRole('button', { name: /^Dungeons/ }).click();
await page.locator('.dungeon-category-tab').filter({ hasText: category }).click();
await page.locator('.dungeon-select-row').filter({ hasText: category === 'WoW' ? 'Wailing Caverns' : 'Forsaken Abbey' }).first().click();
const download = page.getByRole('button', { name: 'Download and continue' });
if (await download.isVisible()) await download.click();
else await page.getByRole('button', { name: 'Choose role' }).click();
await page.locator('.dungeon-role-option--healer').click();
await page.locator('.scene-shell').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'hidden' });
await page.locator('.party-frames').waitFor();
await page.waitForTimeout(12000);
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden' });
await capture(page, `${category}-dungeon`);
// Rotate the camera with the normal held-right-mouse gesture to show the party fronts.
const bounds = await page.locator('.scene-shell canvas').boundingBox();
await page.mouse.move(bounds.x + bounds.width * 0.5, bounds.y + bounds.height * 0.55);
await page.mouse.down({ button: 'right' });
await page.mouse.move(bounds.x + bounds.width * 0.5 + 440, bounds.y + bounds.height * 0.55, { steps: 40 });
await page.mouse.up({ button: 'right' });
await page.waitForTimeout(1200);
await capture(page, `${category}-party-front`);
if (category === 'RoM') {
await page.locator('.scene-shell').focus();
await page.keyboard.down('s');
await page.waitForTimeout(2000);
await page.keyboard.up('s');
await page.mouse.wheel(700, 700);
await page.waitForTimeout(2500);
await capture(page, `${category}-party-open`);
}
assert.deepEqual(errors, []);
results[category] = {
installedNativeCharacterPack: true,
screenshots: [`${category}-roster.png`, `${category}-dungeon.png`, `${category}-party-front.png`,
...(category === 'RoM' ? [`${category}-party-open.png`] : [])],
errors,
};
} catch (error) {
await capture(page, `${category}-failure`);
console.error((await page.locator('body').innerText()).slice(-2500));
console.error(errors);
throw error;
} finally {
await context.close();
}
}
fs.writeFileSync(path.join(output, 'results.json'), `${JSON.stringify(results, null, 2)}\n`);
console.log(JSON.stringify(results, null, 2));
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
assert.equal(path.dirname(path.resolve(dataDir)), path.resolve(os.tmpdir()));
assert.ok(path.basename(dataDir).startsWith('healer-man-equipment-'));
fs.rmSync(dataDir, { recursive: true, force: true });
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+172
View File
@@ -0,0 +1,172 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const label = process.env.PERFORMANCE_LABEL || 'current';
const output = path.resolve('artifacts/playtest/performance', label);
fs.mkdirSync(output, { recursive: true });
async function enterDungeon(page) {
await page.getByRole('button', { name: /^Dungeons/ }).click();
await page.locator('.dungeon-select-row').filter({ hasText: /Wailing Caverns/i }).first().click();
await page.getByRole('button', { name: 'Choose role' }).click();
await page.locator('.dungeon-role-option--healer').click();
await page.locator('.scene-shell').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'hidden', timeout: 180_000 });
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 180_000 });
await page.waitForTimeout(2000);
}
async function measure(page, cdp, label, seconds = 10) {
const read = async () => {
const { metrics } = await cdp.send('Performance.getMetrics');
return Object.fromEntries(metrics.map(({ name, value }) => [name, value]));
};
const before = await read();
const drawsBefore = await page.evaluate(() => window.__performanceDraws);
await page.waitForTimeout(seconds * 1000);
const after = await read();
const drawsAfter = await page.evaluate(() => window.__performanceDraws);
await cdp.send('HeapProfiler.collectGarbage');
const heap = await cdp.send('Runtime.getHeapUsage');
const elapsed = after.Timestamp - before.Timestamp;
const sample = {
label,
seconds: elapsed,
mainThreadBusyPercent: (after.TaskDuration - before.TaskDuration) / elapsed * 100,
scriptPercent: (after.ScriptDuration - before.ScriptDuration) / elapsed * 100,
layoutCount: after.LayoutCount - before.LayoutCount,
drawCallsPerSecond: (drawsAfter - drawsBefore) / elapsed,
heapMiB: heap.usedSize / 1048576,
backingStorageMiB: (heap.backingStorageSize || 0) / 1048576,
nodes: after.Nodes,
documents: after.Documents,
scene: await page.locator('.scene-shell').evaluateAll((elements) => elements.map((element) => ({
frameLoop: element.dataset.frameLoop,
performance: document.documentElement.dataset.gamePerformance || null,
}))),
};
console.log(JSON.stringify(sample));
return sample;
}
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-performance-'));
const runtime = createGameServer({ dataDir, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(4191, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
page.setDefaultTimeout(60_000);
const errors = [];
const samples = [];
page.on('pageerror', (error) => errors.push(error.message));
const cdp = await page.context().newCDPSession(page);
await cdp.send('Performance.enable');
await cdp.send('HeapProfiler.enable');
await page.addInitScript(() => {
window.__performanceDraws = 0;
for (const api of [WebGLRenderingContext, WebGL2RenderingContext]) {
for (const name of ['drawArrays', 'drawElements', 'drawArraysInstanced', 'drawElementsInstanced']) {
const original = api.prototype[name];
if (!original) continue;
api.prototype[name] = function (...args) {
window.__performanceDraws += 1;
return original.apply(this, args);
};
}
}
});
try {
await page.goto(process.env.PERFORMANCE_URL || 'http://127.0.0.1:4191', { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: /Play offline/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('PerformanceQA');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue|Enter world/ }).first().evaluate((button) => button.click());
await page.locator('.main-menu-screen').waitFor();
samples.push(await measure(page, cdp, 'menu-cold', 5));
for (let cycle = 1; cycle <= 3; cycle += 1) {
await enterDungeon(page);
if (cycle === 1) {
const renderer = await page.locator('.scene-shell canvas').first().evaluate((canvas) => {
const gl = canvas.getContext('webgl2') || canvas.getContext('webgl');
const extension = gl?.getExtension('WEBGL_debug_renderer_info');
return { renderer: extension ? gl.getParameter(extension.UNMASKED_RENDERER_WEBGL) : null,
width: canvas.width, height: canvas.height };
});
console.log(JSON.stringify(renderer));
fs.writeFileSync(path.join(output, 'renderer.json'), `${JSON.stringify(renderer, null, 2)}\n`);
samples.push(await measure(page, cdp, 'active-idle'));
await page.screenshot({ path: path.join(output, 'active.png') });
await cdp.send('Profiler.enable');
await cdp.send('Profiler.start');
await page.keyboard.down('w');
await page.waitForTimeout(3000);
await page.keyboard.up('w');
await page.waitForTimeout(5000);
const { profile } = await cdp.send('Profiler.stop');
fs.writeFileSync(path.join(output, 'active.cpuprofile'), JSON.stringify(profile));
samples.push(await measure(page, cdp, 'after-movement', 5));
}
await page.keyboard.press('Escape');
await page.getByRole('dialog', { name: 'Paused' }).waitFor();
await page.waitForTimeout(1500);
if (cycle === 3) {
await cdp.send('Profiler.enable');
await cdp.send('Profiler.start');
}
samples.push(await measure(page, cdp, `paused-cycle-${cycle}`));
if (cycle === 3) {
const { profile } = await cdp.send('Profiler.stop');
fs.writeFileSync(path.join(output, 'paused.cpuprofile'), JSON.stringify(profile));
await page.screenshot({ path: path.join(output, 'paused-cycle-3.png') });
samples.push(await measure(page, cdp, 'paused-cycle-3-settled', 20));
}
if (cycle === 1) {
await page.screenshot({ path: path.join(output, 'paused.png') });
samples.push(await measure(page, cdp, 'paused-retention', 20));
await page.getByRole('button', { name: 'Resume game' }).click();
await page.waitForTimeout(1500);
samples.push(await measure(page, cdp, 'resumed', 5));
await page.screenshot({ path: path.join(output, 'resumed.png') });
// Exercise the app's visibility handlers deterministically. This is a
// synthetic lifecycle check, not a measurement of OS tab throttling.
await page.evaluate(() => {
window.__profileHidden = true;
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => window.__profileHidden ? 'hidden' : 'visible' });
Object.defineProperty(document, 'hidden', { configurable: true, get: () => window.__profileHidden });
document.dispatchEvent(new Event('visibilitychange'));
});
await page.waitForTimeout(1000);
samples.push(await measure(page, cdp, 'synthetic-hidden', 5));
await page.evaluate(() => {
window.__profileHidden = false;
document.dispatchEvent(new Event('visibilitychange'));
});
await page.waitForTimeout(1000);
await page.keyboard.press('Escape');
await page.getByRole('dialog', { name: 'Paused' }).waitFor();
}
await page.getByRole('button', { name: 'Main menu', exact: true }).click();
await page.locator('.main-menu-screen').waitFor();
await page.waitForTimeout(2000);
samples.push(await measure(page, cdp, `menu-cycle-${cycle}`, 5));
}
fs.writeFileSync(path.join(output, 'results.json'), `${JSON.stringify({ samples, errors }, null, 2)}\n`);
if (errors.length) throw new Error(errors.join('\n'));
} catch (error) {
await page.screenshot({ path: path.join(output, 'failure.png') });
fs.writeFileSync(path.join(output, 'results.json'), `${JSON.stringify({ samples, errors }, null, 2)}\n`);
console.error((await page.locator('body').innerText()).slice(-2000));
throw error;
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
const resolved = path.resolve(dataDir);
if (!resolved.startsWith(path.resolve(os.tmpdir()) + path.sep)) throw new Error('Unexpected test directory');
fs.rmSync(resolved, { recursive: true, force: true });
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+38 -3
View File
@@ -276,7 +276,7 @@ async function readStatsPanelPage(page) {
}));
return {
...stats,
caption: await page.locator('.equipment-character-stats__note').innerText().catch(() => ''),
caption: await page.locator('.equipment-character-stats__note').first().innerText().catch(() => ''),
};
}
@@ -509,10 +509,19 @@ async function readStatsPanelPage(page) {
assert.ok(nativeRuntime.inventory.some((item) => item.instanceId === 'qa-robes-native'));
assert.equal(nativeRuntime.playerEquipment.chest, 'qa-robes-native', 'The equipped test item reaches the actual dungeon runtime.');
for (const member of nativeRuntime.party) {
if (member.classId.startsWith('rom-')) assert.equal(member.statRuleset, 'rom', `${member.name} keeps its own RoM ruleset.`);
if (member.classId.startsWith('rom-')) {
assert.equal(member.statRuleset, 'rom', `${member.name} keeps its own RoM ruleset.`);
assert.ok(['rom-knight', 'rom-priest', 'rom-rogue', 'rom-scout', 'rom-mage'].includes(member.classId),
'Ordinary generated RoM companions stay inside the active five classes.');
}
}
results.observations.nativeDungeonRuntime = nativeRuntime;
await openStatsPanel(page);
const trainingGearText = await page.locator('.equipment-loadout').innerText();
assert.match(trainingGearText, /Training gear scales automatically through level 79\./);
assert.match(trainingGearText, /Training gear 30/i);
assert.doesNotMatch(trainingGearText, /\bHeirloom\b/i, 'RoM automatic gear uses training labels, not authentic heirloom claims.');
results.observations.romTrainingGearLabels = true;
const nativePanelPages = {};
for (const [index, pageName] of ['attributes', 'offense', 'defense'].entries()) {
if (pageName !== 'attributes') {
@@ -528,7 +537,7 @@ async function readStatsPanelPage(page) {
const labels = new Set(panelPage.rows.map((row) => row.label));
const missing = requiredNativeRows[pageName].filter((label) => !labels.has(label));
assert.deepEqual(missing, [], `Native RoM ${pageName} page is missing fields: ${missing.join(', ')}`);
assert.equal(panelPage.caption, 'Native RoM stats · shared dungeon combat rules');
assert.equal(panelPage.caption, 'Native RoM stats');
nativePanelPages[pageName] = panelPage;
results.screenshots[`nativeStats${pageName[0].toUpperCase()}${pageName.slice(1)}Desktop`] = await capture(
page,
@@ -560,6 +569,32 @@ async function readStatsPanelPage(page) {
assert.ok(nativePanelPages.attributes.vitals.some((row) => row.includes(`Combat health ${Math.round(nativeRuntime.maxHealth).toLocaleString()}`)));
results.observations.nativePanelPages = { ...nativePanelPages, renderedMeters };
await page.locator('.equipment-stat-tabs').getByRole('tab', { name: 'attributes', exact: true }).click();
const npcMember = nativeRuntime.party.find((member) => member.classId === 'rom-mage')
?? nativeRuntime.party.find((member) => member.classId.startsWith('rom-') && member.statRuleset === 'rom');
assert.ok(npcMember, 'The live party contains a native RoM companion owner to inspect.');
const npcOwnerButton = page.locator('.equipment-panel__owners .equipment-owner').filter({ hasText: npcMember.name });
await npcOwnerButton.waitFor();
await npcOwnerButton.click();
await page.waitForFunction((name) => (
document.querySelector('.equipment-loadout > header strong')?.textContent?.trim() === name
), npcMember.name);
const npcNativeStats = await readStatsPanelPage(page);
const npcNativeLabels = new Set(npcNativeStats.rows.map((row) => row.label));
const missingNpcRows = requiredNativeRows.attributes.filter((label) => !npcNativeLabels.has(label));
assert.deepEqual(missingNpcRows, [], `Native RoM NPC attributes page is missing fields: ${missingNpcRows.join(', ')}`);
assert.equal(npcNativeStats.caption, 'Native RoM stats');
const npcTrainingGearText = await page.locator('.equipment-loadout').innerText();
assert.match(npcTrainingGearText, /Training gear scales automatically through level 79\./);
assert.match(npcTrainingGearText, /Training gear 30/i);
assert.doesNotMatch(npcTrainingGearText, /\bHeirloom\b/i, 'RoM NPC automatic gear uses training labels.');
assert.equal(npcMember?.statRuleset, 'rom', 'The selected NPC owner keeps its own native ruleset.');
results.observations.nativeNpcOwnerPanel = { member: npcMember, stats: npcNativeStats };
results.screenshots.nativeNpcStatsAttributesDesktop = await capture(page, '05-native-npc-stats-attributes-desktop');
const playerOwnerButton = page.locator('.equipment-panel__owners .equipment-owner').filter({ hasText: nativeCreated.name });
await playerOwnerButton.click();
await page.waitForFunction((name) => (
document.querySelector('.equipment-loadout > header strong')?.textContent?.trim() === name
), nativeCreated.name);
await page.locator('.equipment-panel .equipment-character-stats').scrollIntoViewIfNeeded();
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(350);
+793
View File
@@ -0,0 +1,793 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
// UI-driven, final-build Boss Testing proof for Vow of Silence against the
// source-scheduled Lady Anacondra Lightning Bolt. Store access is read-only
// during combat; only the isolated profile's level and secondary class are set.
const artifactRoot = path.resolve('playtest-artifacts/vow-live-final');
const stamp = new Date().toISOString().replace(/[-:.]/g, '').replace('T', '-').replace('Z', '');
const outputDirectory = path.join(artifactRoot, `final-build-${stamp}`);
const dataDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-vow-live-final-'));
const liveFightBudgetMs = 80_000;
const vowSilenceWindowMs = 8_400;
const actionSlots = [
['primary:face-bottom', '1'], ['primary:face-left', '2'], ['primary:face-top', '3'],
['primary:face-right', '4'], ['primary:r1', '5'], ['primary:r2', '6'],
['primary:dpad-left', '7'], ['primary:dpad-right', '8'],
];
fs.mkdirSync(outputDirectory, { recursive: true });
function flatEffects(effects = []) {
return effects.flatMap((effect) => effect.kind === 'composite' ? flatEffects(effect.effects) : [effect]);
}
function actionPlanFromSnapshot(snapshot, vowId) {
const abilities = new Map(snapshot.abilities.map((ability) => [ability.id, ability]));
const bound = actionSlots.flatMap(([bindingId, key]) => {
const id = snapshot.actionBindings[bindingId];
const ability = id ? abilities.get(id) : null;
return ability && ability.id !== vowId && !ability.passive
&& (ability.unlockLevel ?? 1) <= snapshot.player.level
? [{ key, ability, effects: flatEffects(ability.effects) }] : [];
});
const healing = bound.find(({ ability, effects }) => ability.target === 'friendly'
&& effects.some((effect) => effect.kind === 'heal')) ?? null;
const damage = bound.filter(({ ability, effects }) => ability.target === 'hostile'
&& effects.some((effect) => effect.kind === 'damage'))
.sort((left, right) => Number(right.ability.range?.max ?? 0) - Number(left.ability.range?.max ?? 0))[0] ?? null;
return {
healing: healing ? { key: healing.key, id: healing.ability.id, name: healing.ability.name,
range: healing.ability.range ?? null } : null,
damage: damage ? { key: damage.key, id: damage.ability.id, name: damage.ability.name,
range: damage.ability.range ?? null } : null,
bound: bound.map(({ key, ability }) => ({ key, id: ability.id, name: ability.name,
target: ability.target, range: ability.range ?? null })),
};
}
async function readLiveState(page, includeActionSetup = false) {
return page.evaluate((includeSetup) => window.vowFinalStores().then(({ game, combat, party,
getMobPosition, getMobTransform, getPartyPosition }) => {
const gameState = game.getState();
const combatState = combat.getState();
return {
now: Date.now(),
game: {
activeDungeonId: gameState.activeDungeonId,
gameMode: gameState.gameMode,
gmTestMode: gameState.gmTestMode,
bossTest: gameState.bossTest,
assetStatus: gameState.assetStatus,
activeSpawn: gameState.activeSpawn,
playerPosition: gameState.playerPosition,
cameraYaw: gameState.cameraYaw ?? null,
overlay: gameState.overlay,
dungeonIntroActive: gameState.dungeonIntroActive,
},
player: {
classId: combatState.classId,
secondaryClassId: combatState.secondaryClassId,
statRuleset: combatState.statRuleset,
level: combatState.level,
experience: combatState.xp,
health: combatState.health,
maxHealth: combatState.maxHealth,
resource: combatState.resource,
maxResource: combatState.maxResource,
resourceType: combatState.resourceType,
resourceName: combatState.resourceName,
position: gameState.playerPosition,
cameraYaw: gameState.cameraYaw ?? null,
selectedTargetId: combatState.selectedTargetId,
activeCast: combatState.activeCast?.abilityId ?? null,
globalCooldownEndsAt: combatState.globalCooldownEndsAt,
cooldowns: combatState.cooldowns,
},
party: party.getState().members.map((member) => ({
id: member.id,
name: member.name,
classId: member.classId,
secondaryClassId: member.secondaryClassId,
role: member.role,
statRuleset: member.statRuleset,
health: member.health,
maxHealth: member.maxHealth,
resource: member.resource,
maxResource: member.maxResource,
resourceType: member.resourceType,
status: member.status,
activeCast: member.activeCast?.abilityId ?? null,
position: getPartyPosition?.(member.id) ?? null,
})),
mobs: Object.values(combatState.mobs).filter((mob) => !mob.despawned).map((mob) => ({
id: mob.id,
name: mob.name,
serverEntry: mob.serverEntry,
runewakerNpcId: mob.runewakerNpcId ?? null,
creatureFamily: mob.creatureFamily,
level: mob.level,
health: mob.health,
maxHealth: mob.maxHealth,
dead: mob.dead,
diedAt: mob.diedAt ?? null,
engaged: mob.engaged,
hasLoot: mob.hasLoot,
pendingLootCount: mob.pendingLoot?.length ?? 0,
position: getMobPosition?.(mob.id) ?? mob.position ?? mob.homePosition,
transform: getMobTransform?.(mob.id) ?? null,
attackRevision: mob.attackRevision,
abilityCastCounts: mob.abilityCastCounts,
abilityReadyAt: mob.abilityReadyAt,
schoolLockedUntil: mob.schoolLockedUntil,
mechanicImmunities: mob.mechanicImmunities,
schoolImmunities: mob.schoolImmunities,
auras: mob.auras,
activeCast: mob.activeCast ? {
name: mob.activeCast.name,
attackId: mob.activeCast.attackId,
spellId: mob.activeCast.spellId ?? null,
school: mob.activeCast.school,
startedAt: mob.activeCast.startedAt,
completesAt: mob.activeCast.completesAt,
interruptible: mob.activeCast.interruptible,
} : null,
statuses: mob.statuses,
attacks: mob.attacks.map((attack) => ({ id: attack.id, name: attack.name,
spellId: attack.spellId ?? null, castTimeMs: attack.castTimeMs ?? 0,
cooldownMs: attack.cooldownMs ?? 0, school: attack.school,
interruptible: attack.interruptible !== false })),
boss: mob.boss,
})),
...(includeSetup ? { abilities: combatState.abilities, actionBindings: combatState.actionBindings } : {}),
feedback: combatState.feedback ? { kind: combatState.feedback.kind,
message: combatState.feedback.message, abilityId: combatState.feedback.abilityId ?? null,
targetId: combatState.feedback.targetId ?? null } : null,
events: combatState.combatEvents.slice(-200),
presentationEvents: window.vowFinalPresentationEvents.slice(-500),
presentationSubscriptionFound: window.vowFinalPresentationSubscriptionFound ?? false,
presentationSubscribeExportName: window.vowFinalPresentationSubscribeExportName ?? null,
presentationExportNames: window.vowFinalPresentationExportNames ?? [],
presentationSubscriptionCandidates: window.vowFinalPresentationSubscriptionCandidates ?? [],
};
}), includeActionSetup);
}
async function assignVowThroughAbilityControls(page, outputDirectory) {
const before = await readLiveState(page, true);
const vow = before.abilities.find((ability) => ability.dbcSpellId === 490275);
if (!vow) throw new Error('The UI-created Priest/Knight did not learn DBC spell 490275 at the prepared level.');
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
await page.getByRole('button', { name: 'Ability controls' }).click();
await page.locator('.ability-bindings__diagram').waitFor({ timeout: 15_000 });
await page.locator('.ability-bindings__layers button').nth(1).click();
await page.locator('button[aria-label*="Shift+5:"]').click();
await page.getByRole('button', { name: new RegExp(`^${vow.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:,|$)`) }).click();
await page.screenshot({ path: path.join(outputDirectory, 'vow-binding-ui.png') });
const after = await readLiveState(page, true);
const binding = Object.entries(after.actionBindings).find(([, id]) => id === vow.id)?.[0] ?? null;
await page.getByRole('button', { name: 'Close button assignments' }).click();
await page.getByRole('button', { name: 'Resume game' }).click();
assert.equal(binding, 'secondary:r1', 'Vow is bound through the UI to Shift+Digit5.');
return { id: vow.id, name: vow.name, dbcSpellId: vow.dbcSpellId,
unlockLevel: vow.unlockLevel, playerLevel: after.player.level, target: vow.target,
range: vow.range ?? null, radius: vow.radius ?? null, targeting: vow.targeting ?? null,
castMode: vow.castMode, castTimeMs: vow.castTimeMs, cooldownMs: vow.cooldownMs,
gcdMs: vow.gcdMs, cost: vow.cost, effects: vow.effects,
binding, input: 'Shift+Digit5' };
}
async function selectBossWithTab(page, bossId, attempts = 8) {
let snapshot = await readLiveState(page);
for (let attempt = 0; attempt < attempts && snapshot.player.selectedTargetId !== bossId; attempt += 1) {
await page.keyboard.press('Tab');
await page.waitForTimeout(70);
snapshot = await readLiveState(page);
}
return { selected: snapshot.player.selectedTargetId === bossId, snapshot };
}
async function selectPartyMember(page, member) {
const label = new RegExp(`^Target ${member.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')},`);
await page.getByRole('button', { name: label }).click();
}
async function selectPlayerFrame(page, playerName) {
const label = new RegExp(`^Target ${playerName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}, you$`);
await page.getByRole('button', { name: label }).click();
}
async function performCast(page, action, kind, intendedTargetId, reason) {
const before = await readLiveState(page);
await page.keyboard.press(action.key);
const startedAt = Date.now();
let after = await readLiveState(page);
const eventsFor = (state) => state.events.filter((event) => event.occurredAt >= before.now - 1
&& event.abilityId === action.id && event.sourceActorId === '__player__'
&& (kind === 'damage' ? event.kind === 'damage' : event.kind === 'healing'));
while (Date.now() - startedAt < 2_600) {
if (eventsFor(after).some((event) => event.effectiveAmount > 0)
|| (!after.player.activeCast && Date.now() - startedAt >= 650)) break;
await page.waitForTimeout(120);
after = await readLiveState(page);
}
const memberBefore = before.party.find((member) => member.id === intendedTargetId);
const memberAfter = after.party.find((member) => member.id === intendedTargetId);
return {
at: before.now,
key: action.key,
abilityId: action.id,
abilityName: action.name,
kind,
reason,
intendedTargetId,
selectedTargetIdBefore: before.player.selectedTargetId,
targetHealthBefore: kind === 'healing'
? memberBefore?.health ?? (intendedTargetId === '__player__' ? before.player.health : null)
: before.mobs.find((mob) => mob.id === intendedTargetId)?.health ?? null,
targetHealthAfter: kind === 'healing'
? memberAfter?.health ?? (intendedTargetId === '__player__' ? after.player.health : null)
: after.mobs.find((mob) => mob.id === intendedTargetId)?.health ?? null,
resolvedEvents: eventsFor(after),
actionResolved: eventsFor(after).some((event) => event.effectiveAmount > 0),
playerHealthBefore: before.player.health,
playerHealthAfter: after.player.health,
playerResourceBefore: before.player.resource,
playerResourceAfter: after.player.resource,
feedback: after.feedback,
atAfter: after.now,
};
}
function playerVitals(snapshot) {
return { health: snapshot.player.health, maxHealth: snapshot.player.maxHealth,
resource: snapshot.player.resource, maxResource: snapshot.player.maxResource,
resourceType: snapshot.player.resourceType, resourceName: snapshot.player.resourceName };
}
function partyVitals(snapshot) {
return snapshot.party.map((member) => ({ id: member.id, name: member.name,
classId: member.classId, secondaryClassId: member.secondaryClassId, role: member.role,
statRuleset: member.statRuleset, status: member.status,
health: member.health, maxHealth: member.maxHealth, resource: member.resource,
maxResource: member.maxResource, resourceType: member.resourceType }));
}
async function runFight(page, result, vowSetup, initial) {
const bossId = initial.game.bossTest.runtimeIds[0];
let current = initial;
const target = await selectBossWithTab(page, bossId);
if (!target.selected) throw new Error('Boss Testing UI targeting did not select the live Lady Anacondra.');
current = target.snapshot;
await page.keyboard.press('F1');
result.fight.startedAt = Date.now();
result.fight.partyAttackInput = { at: result.fight.startedAt, key: 'F1', command: 'Attack', targetId: bossId };
const wallDeadline = result.fight.startedAt + liveFightBudgetMs;
let vowAttempts = 0;
let nextDamageAt = 0;
let nextHealAt = 0;
let observedCastCountAtWindowStart = null;
let lastSampleAt = 0;
let lastPresentationCursor = current.presentationEvents.at(-1)?.id ?? 0;
let previousCastIdentity = null;
while (Date.now() < wallDeadline) {
current = await readLiveState(page);
const boss = current.mobs.find((mob) => mob.id === bossId);
if (!boss || boss.dead || boss.health <= 0) {
result.fight.outcome = boss?.dead || boss?.health <= 0 ? 'boss-defeated' : 'boss-despawned';
result.fight.bossAtEnd = boss ?? null;
break;
}
if (current.player.health <= 0) { result.fight.outcome = 'player-dead'; break; }
if (lastSampleAt === 0 || current.now - lastSampleAt >= 350) {
result.fight.samples.push({ at: current.now, bossHealth: boss.health, bossCast: boss.activeCast,
lightningBoltCount: boss.abilityCastCounts?.['spell-9532'] ?? null,
natureLockedUntil: boss.schoolLockedUntil?.nature ?? 0,
player: playerVitals(current), party: partyVitals(current) });
lastSampleAt = current.now;
}
for (const event of current.presentationEvents) {
if (event.id <= lastPresentationCursor) continue;
result.fight.presentationEvents.push(event);
lastPresentationCursor = Math.max(lastPresentationCursor, event.id);
}
if (current.player.selectedTargetId !== bossId) {
const selected = await selectBossWithTab(page, bossId, 4);
if (selected.selected) current = selected.snapshot;
}
const liveBoss = current.mobs.find((mob) => mob.id === bossId);
const liveCast = liveBoss?.activeCast;
const castIdentity = liveCast ? `${liveCast.attackId}:${liveCast.startedAt}` : null;
if (castIdentity !== previousCastIdentity) {
result.fight.castTimeline.push({ at: current.now, activeCast: liveCast });
previousCastIdentity = castIdentity;
}
const sourceLightning = liveBoss?.attacks.find((attack) => attack.spellId === 9532
|| attack.id === 'spell-9532') ?? null;
if (!sourceLightning || sourceLightning.castTimeMs <= 0) {
throw new Error('The live Lady Anacondra has no source-scheduled interruptible Lightning Bolt (9532) definition.');
}
if (!result.fight.sourceLightningDefinition) result.fight.sourceLightningDefinition = sourceLightning;
if (!result.fight.vowSuccess && vowAttempts < 4 && liveCast
&& liveCast.attackId === sourceLightning.id && liveCast.spellId === 9532
&& liveCast.interruptible && liveCast.completesAt - current.now > 450
&& current.player.selectedTargetId === bossId
&& current.player.globalCooldownEndsAt <= current.now
&& !current.player.activeCast) {
vowAttempts += 1;
const beforeCast = liveCast;
const presentationBefore = current.presentationEvents.at(-1)?.id ?? 0;
const eventsBefore = current.events.map((event) => event.id);
const attempt = { attempt: vowAttempts, observedAt: current.now,
castStartedAt: beforeCast.startedAt, scheduledCompletionAt: beforeCast.completesAt,
remainingMs: beforeCast.completesAt - current.now, activeCastBefore: beforeCast,
sourceAttack: sourceLightning, input: 'Shift+Digit5', selectedTargetId: current.player.selectedTargetId,
lightningBoltCountBefore: liveBoss.abilityCastCounts?.[sourceLightning.id] ?? null,
natureLockedUntilBefore: liveBoss.schoolLockedUntil?.nature ?? 0,
playerOriginBefore: current.player.position ?? null,
playerCameraYawBefore: current.player.cameraYaw ?? null,
bossPositionBefore: liveBoss.position ?? null,
bossTransformBefore: liveBoss.transform ?? null,
vowTargetingMetadata: { target: vowSetup.target, range: vowSetup.range,
radius: vowSetup.radius, targeting: vowSetup.targeting } };
result.fight.vowAttempts.push(attempt);
await page.screenshot({ path: path.join(outputDirectory, `lightning-bolt-pre-vow-${vowAttempts}.png`) });
await page.keyboard.press('Shift+Digit5');
attempt.inputAt = Date.now();
await page.waitForTimeout(65);
const afterVow = await readLiveState(page);
const afterBoss = afterVow.mobs.find((mob) => mob.id === bossId);
const cancelEvent = afterVow.presentationEvents.find((event) => event.id > presentationBefore
&& event.phase === 'cast-cancel' && event.actorGroup === 'enemy'
&& event.sourceActorId === bossId && event.abilityId === beforeCast.attackId
&& (event.sourceSpellId === 9532 || event.name === sourceLightning.name));
const vowEvents = afterVow.events.filter((event) => !eventsBefore.includes(event.id)
&& event.abilityId === vowSetup.id && event.sourceActorId === '__player__');
const schoolLock = afterBoss?.schoolLockedUntil?.nature ?? 0;
const sameObservedCastStillActive = afterBoss?.activeCast?.attackId === beforeCast.attackId
&& afterBoss.activeCast.startedAt === beforeCast.startedAt;
const sourceCastEndedOnTime = Boolean(!sameObservedCastStillActive && afterVow.now < beforeCast.completesAt);
const appliedSilence = afterBoss?.statuses.find((status) => status.kind === 'silence'
&& status.sourceAbilityId === 'aura:rom-priest-vow-of-silence'
&& status.endsAt > afterVow.now) ?? null;
const silenceAura = afterBoss?.auras.find((aura) => aura.id === 'rom-priest-vow-of-silence'
&& aura.endsAt > afterVow.now) ?? null;
const silenceEndsAt = appliedSilence?.endsAt ?? silenceAura?.endsAt ?? schoolLock;
const silenced = silenceEndsAt >= afterVow.now + 7_900;
attempt.afterReadAt = afterVow.now;
attempt.presentationSubscriptionFound = afterVow.presentationSubscriptionFound;
attempt.presentationSubscribeExportName = afterVow.presentationSubscribeExportName;
attempt.presentationExportNames = afterVow.presentationExportNames;
attempt.presentationSubscriptionCandidates = afterVow.presentationSubscriptionCandidates;
attempt.activeCastAfter = afterBoss?.activeCast ?? null;
attempt.sourceCastEndedBeforeScheduledCompletion = sourceCastEndedOnTime;
attempt.sameObservedCastStillActive = sameObservedCastStillActive;
attempt.castIdentityChangedBeforeScheduledCompletion = Boolean(afterBoss?.activeCast
&& (afterBoss.activeCast.attackId !== beforeCast.attackId
|| afterBoss.activeCast.startedAt !== beforeCast.startedAt)
&& sourceCastEndedOnTime);
attempt.targetSourceIdentityAfter = afterBoss ? { serverEntry: afterBoss.serverEntry,
runewakerNpcId: afterBoss.runewakerNpcId, creatureFamily: afterBoss.creatureFamily } : null;
attempt.targetPositionAfter = afterBoss?.position ?? null;
attempt.targetTransformAfter = afterBoss?.transform ?? null;
attempt.playerOriginBefore = current.player.position ?? null;
attempt.playerCameraYawBefore = current.player.cameraYaw ?? null;
attempt.targetMechanicImmunitiesAfter = afterBoss?.mechanicImmunities ?? [];
attempt.targetSchoolImmunitiesAfter = afterBoss?.schoolImmunities ?? [];
attempt.targetStatusesAfter = afterBoss?.statuses ?? [];
attempt.targetAurasAfter = afterBoss?.auras ?? [];
attempt.castCancelPresentationEvent = cancelEvent ?? null;
attempt.playerVowCombatEvents = vowEvents;
attempt.natureLockedUntil = schoolLock;
attempt.silenceEndsAt = silenceEndsAt;
attempt.silenceDurationMsObserved = silenceEndsAt > 0 ? silenceEndsAt - afterVow.now : 0;
attempt.silenceEvidence = { appliedMobStatus: appliedSilence, appliedMobAura: silenceAura,
schoolLockUntil: schoolLock };
attempt.silenceConfirmed = silenced;
attempt.vowPresentationEvents = afterVow.presentationEvents.filter((event) => event.id > presentationBefore
&& event.actorGroup === 'player' && event.sourceActorId === '__player__'
&& event.abilityId === vowSetup.id);
attempt.presentationEventsAfterInput = afterVow.presentationEvents.filter((event) => event.id > presentationBefore);
attempt.vowEffectEvents = afterVow.events.filter((event) => event.abilityId === vowSetup.id
&& event.sourceActorId === '__player__' && event.occurredAt >= attempt.inputAt);
attempt.confirmedEffectTargets = [{ actorId: afterBoss?.id ?? null,
status: appliedSilence, aura: silenceAura }].filter((effect) => effect.status || effect.aura);
await page.screenshot({ path: path.join(outputDirectory, `lightning-bolt-post-vow-${vowAttempts}.png`) });
current = afterVow;
if (sourceCastEndedOnTime && silenced && vowEvents.some((event) => event.targetActorId === bossId)) {
result.fight.vowSuccess = true;
result.fight.vowSuccessAt = Math.min(...vowEvents.map((event) => event.occurredAt));
result.fight.silenceEndsAt = silenceEndsAt;
result.fight.cancellationEvidence = { liveSourceCastBefore: beforeCast,
activeCastAfter: afterBoss?.activeCast ?? null, sourceCastEndedOnTime,
cancelPresentationEvent: cancelEvent ?? null,
statusAppliedToBoss: appliedSilence, silenceEndsAt };
result.fight.silenceWindow = { startedAt: result.fight.vowSuccessAt, expectedEndsAt: silenceEndsAt,
lightningBoltAttackId: sourceLightning.id,
lightningBoltCountAtStart: afterBoss.abilityCastCounts?.[sourceLightning.id] ?? null,
enemyLightningBoltStarts: [],
statusSamples: [{ at: afterVow.now, active: Boolean(appliedSilence && appliedSilence.endsAt > afterVow.now),
endsAt: appliedSilence?.endsAt ?? silenceEndsAt, activeCast: afterBoss?.activeCast ?? null }],
nonPhysicalCastSamplesDuringActiveStatus: appliedSilence && afterBoss?.activeCast
&& afterBoss.activeCast.school !== 'physical'
? [{ at: afterVow.now, statusEndsAt: appliedSilence.endsAt, activeCast: afterBoss.activeCast }]
: [],
sourceNatureCastResumptionsAfterExpiry: [] };
observedCastCountAtWindowStart = afterBoss.abilityCastCounts?.[sourceLightning.id] ?? null;
await page.keyboard.press('F2');
result.fight.partyDefendInput = { at: Date.now(), key: 'F2', command: 'Defend',
reason: 'hold the actual encounter while observing Vow silence duration' };
}
}
if (result.fight.vowSuccess && current.now < result.fight.silenceEndsAt) {
const silenceSampleBoss = current.mobs.find((mob) => mob.id === bossId);
const silenceSampleCast = silenceSampleBoss?.activeCast;
const activeSilenceStatus = silenceSampleBoss?.statuses.find((status) => status.kind === 'silence'
&& status.sourceAbilityId === 'aura:rom-priest-vow-of-silence' && status.endsAt > current.now) ?? null;
result.fight.silenceWindow.statusSamples.push({ at: current.now,
active: Boolean(activeSilenceStatus), endsAt: activeSilenceStatus?.endsAt ?? null,
activeCast: silenceSampleCast ?? null });
if (activeSilenceStatus && silenceSampleCast && silenceSampleCast.school !== 'physical') {
result.fight.silenceWindow.nonPhysicalCastSamplesDuringActiveStatus.push({ at: current.now,
statusEndsAt: activeSilenceStatus.endsAt, activeCast: silenceSampleCast });
}
const startsDuringSilence = current.presentationEvents.filter((event) => event.actorGroup === 'enemy'
&& event.sourceActorId === bossId && event.abilityId === result.fight.silenceWindow.lightningBoltAttackId
&& event.phase === 'cast-start' && event.occurredAt >= result.fight.vowSuccessAt
&& event.occurredAt < result.fight.silenceEndsAt);
result.fight.silenceWindow.enemyLightningBoltStarts = startsDuringSilence;
const sampleCount = silenceSampleBoss?.abilityCastCounts?.[result.fight.silenceWindow.lightningBoltAttackId] ?? null;
result.fight.silenceWindow.lightningBoltCountAtLastSample = sampleCount;
if (sampleCount !== observedCastCountAtWindowStart || silenceSampleCast?.attackId === result.fight.silenceWindow.lightningBoltAttackId) {
result.fight.silenceWindow.suppressionFailedAt = current.now;
result.fight.outcome = 'lightning-bolt-started-during-silence';
break;
}
const injured = current.party.filter((member) => member.health > 0
&& member.health < member.maxHealth * 0.72 && member.position)
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0] ?? null;
const selfInjured = current.player.health < current.player.maxHealth * 0.72;
if ((injured || selfInjured) && result.actionPlan.healing && Date.now() - nextHealAt > 1_100) {
const self = selfInjured && (!injured || current.player.health / current.player.maxHealth
< injured.health / injured.maxHealth);
if (self) await selectPlayerFrame(page, result.profile.name);
else await selectPartyMember(page, injured);
const cast = await performCast(page, result.actionPlan.healing, 'healing', self ? '__player__' : injured.id,
self ? 'Boss Testing Vow silence window survival' : `Boss Testing triage for ${injured.name}`);
result.fight.healingActions.push(cast);
nextHealAt = Date.now();
}
await page.waitForTimeout(100);
continue;
}
if (result.fight.vowSuccess && !result.fight.silenceWindow.verifiedAt
&& current.now >= result.fight.silenceEndsAt) {
const finalBoss = current.mobs.find((mob) => mob.id === bossId);
const durationStarts = current.presentationEvents.filter((event) => event.actorGroup === 'enemy'
&& event.sourceActorId === bossId && event.abilityId === result.fight.silenceWindow.lightningBoltAttackId
&& event.phase === 'cast-start' && event.occurredAt >= result.fight.vowSuccessAt
&& event.occurredAt < result.fight.silenceEndsAt);
result.fight.silenceWindow.enemyLightningBoltStarts = durationStarts;
result.fight.silenceWindow.lightningBoltCountAtEnd = finalBoss?.abilityCastCounts?.[result.fight.silenceWindow.lightningBoltAttackId] ?? null;
result.fight.silenceWindow.verifiedAt = current.now;
result.fight.silenceWindow.durationMsObserved = Math.min(current.now, result.fight.silenceEndsAt)
- result.fight.vowSuccessAt;
result.fight.silenceWindow.verificationLagMs = current.now - result.fight.silenceEndsAt;
result.fight.silenceWindow.nonPhysicalCastStartsDuringSilence = result.fight.castTimeline.filter(({ activeCast }) => (
activeCast && activeCast.school !== 'physical'
&& activeCast.startedAt >= result.fight.vowSuccessAt
&& activeCast.startedAt < result.fight.silenceEndsAt
));
result.fight.silenceWindow.fullSilenceSuppressionPassed = result.fight.silenceWindow.statusSamples.some((sample) => sample.active)
&& result.fight.silenceWindow.nonPhysicalCastSamplesDuringActiveStatus.length === 0
&& result.fight.silenceWindow.nonPhysicalCastStartsDuringSilence.length === 0;
result.fight.silenceWindow.suppressedNextCast = durationStarts.length === 0
&& result.fight.silenceWindow.lightningBoltCountAtEnd === observedCastCountAtWindowStart;
if (!result.fight.silenceWindow.suppressedNextCast) {
result.fight.outcome = 'lightning-bolt-not-suppressed-through-silence';
break;
}
await page.keyboard.press('F1');
result.fight.partyAttackResumedAt = Date.now();
}
if (result.fight.silenceWindow?.verifiedAt && current.now >= result.fight.silenceEndsAt) {
const expiredWindowBoss = current.mobs.find((mob) => mob.id === bossId);
const resumedCast = expiredWindowBoss?.activeCast;
const resumedAttack = resumedCast && expiredWindowBoss.attacks.find((attack) => attack.id === resumedCast.attackId);
if (resumedCast && resumedCast.startedAt >= result.fight.silenceEndsAt
&& (resumedCast.school === 'nature' || resumedAttack?.school === 'nature')
&& !result.fight.silenceWindow.sourceNatureCastResumptionsAfterExpiry.some((entry) => (
entry.activeCast.attackId === resumedCast.attackId && entry.activeCast.startedAt === resumedCast.startedAt
))) {
result.fight.silenceWindow.sourceNatureCastResumptionsAfterExpiry.push({ at: current.now,
activeCast: resumedCast, sourceAttack: resumedAttack ?? null });
}
}
const injured = current.party.filter((member) => member.health > 0
&& member.health < member.maxHealth * 0.72 && member.position)
.sort((left, right) => left.health / left.maxHealth - right.health / right.maxHealth)[0] ?? null;
const selfInjured = current.player.health < current.player.maxHealth * 0.72;
if ((injured || selfInjured) && result.actionPlan.healing && Date.now() - nextHealAt > 1_100) {
const self = selfInjured && (!injured || current.player.health / current.player.maxHealth
< injured.health / injured.maxHealth);
if (self) await selectPlayerFrame(page, result.profile.name);
else await selectPartyMember(page, injured);
const cast = await performCast(page, result.actionPlan.healing, 'healing', self ? '__player__' : injured.id,
self ? 'Boss Testing live-fight survival' : `Boss Testing triage for ${injured.name}`);
result.fight.healingActions.push(cast);
nextHealAt = Date.now();
continue;
}
if (result.fight.vowSuccess && result.fight.silenceWindow.suppressedNextCast
&& result.actionPlan.damage && current.player.selectedTargetId === bossId
&& !current.player.activeCast && current.player.globalCooldownEndsAt <= current.now
&& Date.now() - nextDamageAt > 1_300) {
const cast = await performCast(page, result.actionPlan.damage, 'damage', bossId,
'normal keyboard damage in Lady Anacondra Boss Testing');
result.fight.damageActions.push(cast);
nextDamageAt = Date.now();
continue;
}
await page.waitForTimeout(90);
}
if (result.fight.outcome === 'in-progress') result.fight.outcome = '80-second-live-fight-budget-ended';
const finalState = await readLiveState(page);
const finalBoss = finalState.mobs.find((mob) => mob.id === bossId) ?? null;
result.fight.endedAt = Date.now();
result.fight.durationMs = result.fight.endedAt - result.fight.startedAt;
result.fight.finalVitals = { player: playerVitals(finalState), party: partyVitals(finalState) };
result.fight.bossAtFinalRead = finalBoss;
result.fight.bossFate = finalBoss?.dead || finalBoss?.health <= 0 ? 'defeated'
: finalBoss ? 'alive-at-budget-end' : 'despawned-or-unavailable';
result.fight.partySurvivors = finalState.party.filter((member) => member.health > 0).map((member) => member.name);
result.fight.partyDeaths = finalState.party.filter((member) => member.health <= 0).map((member) => member.name);
result.fight.playerSurvived = finalState.player.health > 0;
result.fight.finalPlayerTargetId = finalState.player.selectedTargetId;
result.fight.experienceBefore = result.fight.preFight.experience;
result.fight.experienceAfter = finalState.player.experience;
result.fight.noExperienceAwardObserved = finalState.player.experience === result.fight.preFight.experience;
result.fight.noLootObserved = Boolean(finalBoss
&& !finalBoss.hasLoot && finalBoss.pendingLootCount === 0);
result.fight.finalEvents = finalState.events;
await page.screenshot({ path: path.join(outputDirectory, 'fight-end.png') });
return finalState;
}
(async () => {
let runtime = null;
let browser = null;
const pageErrors = [];
const consoleErrors = [];
const assetFiles = fs.readdirSync('dist/assets')
.filter((file) => /^(?:index|GameRuntime|combatStore|partyStore|PartyFrames|romAbilityCatalog)-.*\.js$/.test(file)).sort();
const cssAssetFiles = fs.readdirSync('dist/assets')
.filter((file) => /^index-.*\.css$/.test(file)).sort();
const assetSnapshotAtStart = [...assetFiles, ...cssAssetFiles].map((file) => {
const absolute = path.resolve('dist/assets', file);
const bytes = fs.readFileSync(absolute);
return { file, bytes: bytes.length, sha256: crypto.createHash('sha256').update(bytes).digest('hex') };
});
const moduleUrls = assetFiles.map((file) => `/assets/${file}`);
const result = {
status: 'running',
buildCapturedAtScriptStart: new Date().toISOString(),
snapshotKind: 'final production dist; dedicated Boss Testing fight only',
outputDirectory,
assetSnapshotAtStart,
liveFightBudgetMs,
scope: { mode: 'Boss Testing', boss: 'Lady Anacondra', mechanic: 'Vow of Silence (DBC 490275) interrupts source Lightning Bolt (9532)',
combatStoreWritesDuringLiveFight: false, stagedEnemyCast: false, allowedSetup: ['UI-created RoM Priest', 'level 30', 'RoM secondary class Knight',
'Vow bound through Ability controls UI to Shift+Digit5'], lootExpected: false, readinessChanges: false },
profile: null,
partyAtEntry: [],
vowSetup: null,
actionPlan: null,
fight: { outcome: 'in-progress', vowAttempts: [], vowSuccess: false,
damageActions: [], healingActions: [], samples: [], castTimeline: [], presentationEvents: [], pageErrors: [] },
pageErrors,
consoleErrors,
};
try {
if (assetFiles.length < 3) throw new Error(`Expected final production assets are missing: ${assetFiles.join(', ')}`);
const { createGameServer } = await import('../server/server.mjs');
runtime = createGameServer({ dataDir: dataDirectory, enableShelvedModes: false });
await new Promise((resolve, reject) => {
runtime.server.once('error', reject);
runtime.server.listen(0, '127.0.0.1', resolve);
});
const address = runtime.server.address();
browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.setDefaultTimeout(120_000);
page.on('pageerror', (error) => pageErrors.push(error.message));
page.on('console', (message) => {
if (message.type() === 'error') consoleErrors.push({ text: message.text(), location: message.location() });
});
await page.addInitScript(({ urls }) => {
window.vowFinalPresentationEvents = [];
window.vowFinalStores = async () => {
const modules = await Promise.all(urls.map((url) => import(url)));
const namedExports = modules.flatMap((module) => Object.entries(module));
const exports = namedExports.map(([, value]) => value);
const stores = exports.filter((value) => value && typeof value.getState === 'function');
const subscribe = namedExports.find(([name, value]) => name === 'subscribeCombatPresentation'
&& typeof value === 'function');
window.vowFinalPresentationExportNames = namedExports.map(([name]) => name)
.filter((name) => name.toLowerCase().includes('presentation'));
if (subscribe && !window.vowFinalUnsubscribe) {
window.vowFinalPresentationSubscriptionFound = true;
window.vowFinalPresentationSubscribeExportName = subscribe[0];
window.vowFinalUnsubscribe = subscribe[1]((message) => {
if (message?.kind === 'event') window.vowFinalPresentationEvents.push(message.event);
if (window.vowFinalPresentationEvents.length > 500) window.vowFinalPresentationEvents.shift();
});
}
const transformExport = namedExports.find(([, value]) => {
if (typeof value !== 'function') return false;
const source = Function.prototype.toString.call(value);
return source.includes('.mounted') && source.includes('.value') && source.includes('position:')
&& source.includes('yaw:') && source.includes('null');
});
const partyPositionExport = namedExports.find(([, value]) => {
if (typeof value !== 'function') return false;
const source = Function.prototype.toString.call(value);
return /\.get\(/.test(source)
&& /return\s+[A-Za-z_$][\w$]*\s*\?\s*\[\s*\.\.\.[A-Za-z_$][\w$]*\s*\]\s*:\s*null/.test(source);
});
const presentationSubscriptionCandidates = namedExports.filter(([, value]) => {
if (typeof value !== 'function') return false;
const source = Function.prototype.toString.call(value);
return source.includes('.add(') && source.includes('.delete(') && source.includes('=>');
}).map(([name, value]) => ({ name, source: Function.prototype.toString.call(value) }));
window.vowFinalPresentationSubscriptionCandidates = presentationSubscriptionCandidates;
return {
getMobPosition: transformExport?.[1] ? (id) => transformExport[1](id)?.position ?? null : null,
getMobTransform: transformExport?.[1] ?? null,
getPartyPosition: partyPositionExport?.[1] ?? null,
game: stores.find((store) => typeof store.getState()?.relocatePlayer === 'function'),
shell: stores.find((store) => typeof store.getState()?.saveActiveProgression === 'function'),
combat: stores.find((store) => typeof store.getState()?.castAbility === 'function'),
party: stores.find((store) => typeof store.getState()?.setCommand === 'function'
&& Array.isArray(store.getState()?.members)),
};
};
}, { urls: moduleUrls });
await page.goto(`http://127.0.0.1:${address.port}`, { waitUntil: 'domcontentloaded' });
await page.getByRole('button', { name: /Play offline/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
await page.locator('.class-option').filter({ hasText: /^Priest/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('VowLiveFinalQA');
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.character-select-screen').waitFor();
const profile = await page.evaluate(async () => {
const { shell } = await window.vowFinalStores();
const state = shell.getState();
const character = state.characters.find((entry) => entry.id === state.selectedCharacterId);
if (!character || character.classId !== 'rom-priest') throw new Error('UI-created RoM Priest profile is missing.');
shell.setState({ activeCharacter: character });
shell.getState().saveActiveProgression(30, 0, character.talentRanks);
shell.setState({ activeCharacter: null });
if (!shell.getState().chooseRomSecondaryClass('rom-knight')) throw new Error('Priest/Knight pairing was not accepted.');
const updated = shell.getState().characters.find((entry) => entry.id === character.id);
return { id: updated.id, name: updated.name, classId: updated.classId,
secondaryClassId: updated.secondaryClassId, level: updated.level, categoryId: updated.categoryId };
});
assert.equal(profile.classId, 'rom-priest');
assert.equal(profile.secondaryClassId, 'rom-knight');
assert.equal(profile.level, 30);
result.profile = profile;
await page.waitForTimeout(1_000);
await page.screenshot({ path: path.join(outputDirectory, 'roster-priest-knight-level-30.png') });
await page.locator('.roster-enter').click();
await page.locator('.main-menu-screen').waitFor();
await page.getByRole('button', { name: /^GM Menu/ }).click();
await page.getByRole('button', { name: /Boss Testing/ }).click();
await page.getByRole('heading', { name: 'Boss Testing', exact: true }).waitFor();
const picker = page.getByLabel(/Choose a boss/);
const option = await picker.locator('option').evaluateAll((options) => options
.find((entry) => entry.textContent.startsWith('Lady Anacondra ·'))?.value ?? null);
assert(option, 'Lady Anacondra appears in the dedicated Boss Testing catalog.');
await picker.selectOption(option);
const selectedArticle = page.locator('.boss-testing-selected');
await selectedArticle.getByRole('heading', { name: 'Lady Anacondra', exact: true }).waitFor();
await page.screenshot({ path: path.join(outputDirectory, 'boss-testing-lady-selection.png') });
await page.getByRole('button', { name: 'Test selected boss', exact: true }).click();
await page.locator('.party-frames').waitFor({ timeout: 180_000 });
await page.locator('.loading-screen').waitFor({ state: 'detached', timeout: 180_000 }).catch(() => {});
await page.waitForFunction(async () => {
const { game, combat } = await window.vowFinalStores();
const current = game.getState();
const boss = current.bossTest;
return current.assetStatus === 'ready' && boss?.name === 'Lady Anacondra'
&& boss.runtimeIds.every((id) => combat.getState().mobs[id]);
}, null, { timeout: 240_000 });
await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached', timeout: 15_000 }).catch(() => {});
await page.evaluate(() => document.activeElement instanceof HTMLElement && document.activeElement.blur());
let entry = await readLiveState(page, true);
assert.equal(entry.game.bossTest?.name, 'Lady Anacondra');
assert.equal(entry.game.gmTestMode, true, 'Selected room is actual Boss Testing, through its UI flow.');
assert.equal(entry.player.classId, 'rom-priest');
assert.equal(entry.player.secondaryClassId, 'rom-knight');
assert.equal(entry.party.length, 4, 'Boss Testing generated four owned NPC companions.');
assert(entry.party.every((member) => member.classId.startsWith('rom-') && member.statRuleset === 'rom'),
'All four NPCs have native RoM identities.');
result.partyAtEntry = partyVitals(entry);
result.partyIdentityAtEntry = entry.party.map((member) => ({ id: member.id, name: member.name,
classId: member.classId, secondaryClassId: member.secondaryClassId, role: member.role,
statRuleset: member.statRuleset }));
await page.screenshot({ path: path.join(outputDirectory, 'lady-anacondra-before-vow.png') });
result.vowSetup = await assignVowThroughAbilityControls(page, outputDirectory);
entry = await readLiveState(page, true);
result.actionPlan = actionPlanFromSnapshot(entry, result.vowSetup.id);
if (!result.actionPlan.healing) throw new Error('No normal keyboard-bound Priest healing action is available.');
const boss = entry.mobs.find((mob) => mob.id === entry.game.bossTest.runtimeIds[0]);
if (!boss || !boss.boss || boss.name !== 'Lady Anacondra') throw new Error('Boss Testing did not spawn the expected boss.');
result.fight.preFight = { at: entry.now, bossId: boss.id, boss: { id: boss.id, name: boss.name,
level: boss.level, health: boss.health, maxHealth: boss.maxHealth,
attacks: boss.attacks, abilityCastCounts: boss.abilityCastCounts },
player: playerVitals(entry), party: partyVitals(entry), game: entry.game,
experience: entry.player.experience };
await page.screenshot({ path: path.join(outputDirectory, 'lady-anacondra-fight-start.png') });
await runFight(page, result, result.vowSetup, entry);
assert.equal(result.fight.vowSuccess, true,
'A live scheduled Lightning Bolt ended early and Vow applied its full-duration silence status.');
assert.equal(result.fight.silenceWindow.suppressedNextCast, true,
'No subsequent Lightning Bolt began during the observed silence window.');
assert.equal(result.fight.silenceWindow.fullSilenceSuppressionPassed, true,
'No nonphysical Lady Anacondra cast became active during Vow silence.');
assert.equal(result.fight.noLootObserved, true, 'Boss Testing did not create boss loot.');
assert.equal(result.fight.noExperienceAwardObserved, true, 'Boss Testing did not award XP.');
assert.deepEqual(pageErrors, [], `No browser page exceptions: ${JSON.stringify(pageErrors)}`);
result.status = 'complete';
result.pageErrors = pageErrors;
result.consoleErrors = consoleErrors;
result.fight.pageErrors = pageErrors;
result.completedAt = new Date().toISOString();
fs.writeFileSync(path.join(outputDirectory, 'results.json'), `${JSON.stringify(result, null, 2)}\n`);
console.log(`Live Vow of Silence QA complete: ${outputDirectory}`);
} catch (error) {
result.status = 'failed';
result.error = error.stack ?? error.message;
result.pageErrors = pageErrors;
result.consoleErrors = consoleErrors;
result.fight.pageErrors = pageErrors;
result.completedAt = new Date().toISOString();
if (browser) {
const contexts = browser.contexts();
const page = contexts[0]?.pages()[0];
if (page) await page.screenshot({ path: path.join(outputDirectory, 'failure.png') }).catch(() => {});
if (page) result.failureBodyText = (await page.locator('body').innerText().catch(() => '')).slice(-3_000);
}
fs.writeFileSync(path.join(outputDirectory, 'results.json'), `${JSON.stringify(result, null, 2)}\n`);
console.error(`Live Vow of Silence QA failed: ${outputDirectory}`);
throw error;
} finally {
if (browser) await browser.close();
if (runtime) await new Promise((resolve) => runtime.server.close(resolve));
const target = fs.realpathSync(dataDirectory);
if (path.dirname(target) !== fs.realpathSync(os.tmpdir())
|| !path.basename(target).startsWith('healer-man-vow-live-final-')) {
throw new Error('Unsafe temporary playtest database cleanup path.');
}
fs.rmSync(target, { recursive: true, force: true });
}
})().catch((error) => {
console.error(error.stack ?? error.message);
process.exitCode = 1;
});
@@ -483,6 +483,9 @@ function attacksFor(template, sourceSpells, spellCatalog, bossMechanics) {
delivery: spell.runtime.delivery,
target: spell.runtime.target,
school: spell.runtime.school,
damageOrigin: "runewaker",
attackKind: spell.runtime.attackKind,
runewakerEffectType: spell.runtime.runewakerEffectType,
animation: spell.runtime.animation,
range: spell.runtime.range,
cooldownMs: spell.runtime.cooldownMs,
@@ -549,6 +552,7 @@ function entityFor(template, actor, sourceSpells, spellCatalog, bossMechanics) {
...(boss && template.title ? { title: template.title } : {}),
hasLoot: template.hasLoot === true,
combat: {
runewakerNpcId: Number(template.id),
level: template.level,
healthMultiplier: combat.healthMultiplier ?? (boss ? defaults.bossHealthMultiplier ?? 7.5 : defaults.healthMultiplier ?? 1),
damageMultiplier: combat.damageMultiplier ?? (boss ? defaults.bossDamageMultiplier ?? 1.3 : defaults.damageMultiplier ?? 1),
@@ -0,0 +1,312 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { projectRoot, readJson, sourceRootFor } from "./lib/recipe.mjs";
const DATA_START = 140;
const NPC_SIZE = 2224;
const TABLE_SIZE = 408;
const NPC_TYPE = 1;
const NPC_RACE = 176;
const NPC_SPELL_START = 568;
const NPC_SPELL_SIZE = 28;
const NPC_RESIST_START = 888;
const NPC_BOW_EQUIPMENT = 1260;
const NPC_ORNAMENT_EQUIPMENT = 1648;
const SPELL_COUNT = 8;
const FIX_FIELDS = [
"damageAttackCoefficient", "magicalDamageAttackCoefficient", "criticalLevelCoefficient", "damageLevelCoefficient",
"missLevelCoefficient", "secondHandMissCoefficient", "secondHandDamageCoefficient", "basePhysicalCritical",
"basePhysicalCriticalPower", "baseMagicalCritical", "baseMagicalCriticalPower", "magicDamageCoefficient",
"magicMagicalDamageCoefficient", "dotMagicalDamageCoefficient", "healingMagicalDamageCoefficient",
"healingDotMagicalDamageCoefficient", "baseRateCoefficient", "baseResistCoefficient", "levelRateCoefficient",
"levelResistCoefficient", "physicalHitBase", "physicalHitRange", "physicalHitAgility", "physicalDodgeAgility",
"magicalHitBase", "magicalHitRange", "magicalHitWisdom", "magicalDodgeAgility", "magicalDodgeWisdom",
"phantomRankCoefficient", "phantomLevelCoefficient",
];
const CLASS_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const NPC_CLASS_IDS = [0, ...CLASS_IDS];
const STAT_NAMES = ["strength", "stamina", "intellect", "wisdom", "agility", "health", "mana"];
const GROWTH_NAMES = [
"baseStrengthLevelOne", "baseStaminaLevelOne", "baseIntellectLevelOne", "baseWisdomLevelOne",
"baseAgilityLevelOne", "baseHealthLevelOne", "baseManaLevelOne", "baseStrengthFlatGrowth",
"baseStaminaFlatGrowth", "baseIntellectFlatGrowth", "baseWisdomFlatGrowth", "baseAgilityFlatGrowth",
"baseHealthFlatGrowth", "baseManaFlatGrowth", "strengthGrowthRate", "staminaGrowthRate", "intellectGrowthRate",
"wisdomGrowthRate", "agilityGrowthRate", "healthGrowthRate", "manaGrowthRate", "hpStrength", "hpStamina",
"mpIntellect", "mpWisdom", "attackStrength", "attackIntellect", "attackAgility", "defenseStamina",
"baseHealthRecovery", "baseManaRecovery", "healthRecoveryStamina", "manaRecoveryWisdom",
"magicalAttackIntellect", "magicalDefenseWisdom",
];
const recipeDirectory = path.join(projectRoot, "scripts/runewaker-pipeline/recipes");
const recipes = await Promise.all((await readdir(recipeDirectory))
.filter((name) => name.endsWith("-population.json"))
.sort()
.map((name) => readJson(path.join(recipeDirectory, name))));
if (!recipes.length) throw new Error("No population recipes found.");
const sourceRoot = sourceRootFor(recipes[0]);
const dataRoot = path.join(sourceRoot, recipes[0].source.resourceRoot ?? "Resource", "data");
const readSource = (name) => readFile(path.join(dataRoot, name));
const [npcDb, collectDb, magicDb, vocDb, raceDb, sexDb, fixDb, skillBaseDb, jobSkillDb] = await Promise.all([
readSource("npcobject.db"), readSource("magiccollectobject.db"), readSource("magicobject.db"),
readSource("voctable.db"), readSource("racetable.db"), readSource("sextable.db"), readSource("fixtable.db"),
readSource("skillbasetable.db"), readSource("jobskill.db"),
]);
const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
const int32 = (buffer, offset) => buffer.readInt32LE(offset);
const float32 = (buffer, offset) => {
const value = buffer.readFloatLE(offset);
if (!Number.isFinite(value)) throw new Error(`Non-finite native source float at ${offset}.`);
return Number(value.toFixed(6));
};
function fixedRecords(buffer, size, type, label) {
const count = int32(buffer, 132);
if (count <= 0 || DATA_START + count * size > buffer.length) throw new Error(`${label} fixed record layout changed.`);
if (type !== null) {
for (const i of [0, Math.floor(count / 2), count - 1]) {
if (int32(buffer, DATA_START + i * size + 4) !== type) throw new Error(`${label} type sentinel drifted.`);
}
}
return count;
}
function indexById(buffer, size, count) {
const result = new Map();
for (let i = 0; i < count; i += 1) {
const offset = DATA_START + i * size;
const id = int32(buffer, offset);
if (id > 0) result.set(id, offset);
}
return result;
}
const npcCount = fixedRecords(npcDb, NPC_SIZE, NPC_TYPE, "NPCObjectDB");
const collectCount = fixedRecords(collectDb, 1032, 21, "MagicCollectObjectDB");
const magicCount = fixedRecords(magicDb, 992, 3, "MagicObjectDB");
const npcOffsets = indexById(npcDb, NPC_SIZE, npcCount);
const collectOffsets = indexById(collectDb, 1032, collectCount);
const magicOffsets = indexById(magicDb, 992, magicCount);
function npcRow(id) {
const offset = npcOffsets.get(id);
if (offset === undefined || int32(npcDb, offset + 4) !== NPC_TYPE) throw new Error(`Missing NPCObjectDB row ${id}.`);
const int = (delta) => int32(npcDb, offset + delta);
const spells = [];
for (let slot = 0; slot < SPELL_COUNT; slot += 1) {
const start = offset + NPC_SPELL_START + slot * NPC_SPELL_SIZE;
const sourceSkillId = int32(npcDb, start + 12);
const magicLevel = int32(npcDb, start + 16);
if (sourceSkillId <= 0 || !collectOffsets.has(sourceSkillId)) continue;
spells.push({ slot, rightTime: int32(npcDb, start), targetType: int32(npcDb, start + 4), rate: int32(npcDb, start + 8), sourceSkillId, magicLevel });
}
return {
npcId: id,
raceId: int(NPC_RACE),
vocationId: int(NPC_RACE + 4),
vocationSubId: int(NPC_RACE + 8),
sexId: int(NPC_RACE + 12),
level: int(NPC_RACE + 24),
levelRange: int(NPC_RACE + 28),
authoredAttributes: {
strength: int(NPC_RACE + 32), stamina: int(NPC_RACE + 36), intellect: int(NPC_RACE + 40),
wisdom: int(NPC_RACE + 44), agility: int(NPC_RACE + 48),
},
nativeMagicResistances: Array.from({ length: 6 }, (_, type) => float32(npcDb, offset + NPC_RESIST_START + type * 4)),
// RD_Setting chooses rand()%5: keep zero rows and duplicates so later
// equipment resolution can preserve the source candidate distribution.
mainHandEquipmentIds: Array.from({ length: 5 }, (_, i) => int(412 + i * 4)),
offHandEquipmentIds: Array.from({ length: 5 }, (_, i) => int(432 + i * 4)),
bowEquipmentId: int(NPC_BOW_EQUIPMENT),
ornamentEquipmentId: int(NPC_ORNAMENT_EQUIPMENT),
spellBindings: spells.map((spell) => ({ ...spell, ...collectBinding(spell.sourceSkillId) })),
};
}
function componentDescriptor(componentId) {
const offset = magicOffsets.get(componentId);
if (offset === undefined) return null;
const settingFlags = int32(magicDb, offset + 0xd8);
const settingExFlags = int32(magicDb, offset + 988);
return {
componentId,
magicFunc: int32(magicDb, offset + 0xb0),
magicType: int32(magicDb, offset + 0xb4),
effectType: int32(magicDb, offset + 0xb8),
effectTimeSeconds: float32(magicDb, offset + 0xbc),
levelDeltaEffectTime: float32(magicDb, offset + 0xc0),
effectTimeSkillLevelArg: float32(magicDb, offset + 0xc4),
maxBuffLevelBase: int32(magicDb, offset + 0xcc),
maxBuffLevelSkillArg: float32(magicDb, offset + 0xd0),
settingFlags,
isDotMagic: (settingFlags & (1 << 3)) !== 0,
isShowPowerLv: (settingFlags & (1 << 14)) !== 0,
isNoKill: (settingFlags & (1 << 15)) !== 0,
isTargetBound: (settingFlags & (1 << 17)) !== 0,
isFixDotPower: (settingFlags & (1 << 20)) !== 0,
dotTimeSeconds: int32(magicDb, offset + 0x184),
dotType: int32(magicDb, offset + 0x188),
dotBase: int32(magicDb, offset + 0x18c),
dotSkillLevelArg: float32(magicDb, offset + 0x190),
attackType: int32(magicDb, offset + 0x194),
damagePower: float32(magicDb, offset + 0x198),
damagePowerSkillLevelArg: float32(magicDb, offset + 0x19c),
fixedValue: float32(magicDb, offset + 0x1a0),
fixedType: int32(magicDb, offset + 0x1a4),
fixedDamageSkillLevelArg: float32(magicDb, offset + 0x1a8),
randomRange: float32(magicDb, offset + 0x1ac),
criticalRate: int32(magicDb, offset + 0x1b0),
calculationType: int32(magicDb, offset + 0x2d4),
buffMaxLevel: int32(magicDb, offset + 0x3b4),
ignoreMagicAbsorb: (settingExFlags & 1) !== 0,
rawSettingExFlags: settingExFlags,
isFixDmgEffect: false,
};
}
function collectBinding(sourceSkillId) {
const offset = collectOffsets.get(sourceSkillId);
if (offset === undefined) return { missingSource: true, components: [] };
const componentIds = new Set();
for (let field = 0x180; field <= 0x2c0; field += 4) {
const id = int32(collectDb, offset + field);
if (magicOffsets.has(id)) componentIds.add(id);
}
const rawCollectMode = int32(collectDb, offset + 0x368);
return {
magicCollect: {
effectType: int32(collectDb, offset + 0xb4),
rangeType: int32(collectDb, offset + 0xc4),
decreaseArg: int32(collectDb, offset + 0xcc),
hitRateFunc: int32(collectDb, offset + 0xd4),
hitRateArgs: [float32(collectDb, offset + 0xd8), float32(collectDb, offset + 0xdc)],
spellTimeSeconds: float32(collectDb, offset + 0x114),
rawSettingExFlags: int32(collectDb, offset + 0x110),
rawCollectMode,
isFixDmgEffect: (rawCollectMode & (1 << 16)) !== 0,
isShareDamage: (rawCollectMode & (1 << 18)) !== 0,
},
components: [...componentIds].sort((a, b) => a - b).map(componentDescriptor).filter(Boolean),
};
}
const entityIdToNpcId = {};
const npcIds = new Set();
for (const recipe of recipes) {
const generatedPath = path.join(projectRoot, recipe.files.generatedSource);
const generated = await readFile(generatedPath, "utf8");
const runtimePrefix = recipe.runtimeIdPrefix ?? `rw-${recipe.dungeonId}`;
const entityPattern = new RegExp(`"(${runtimePrefix}-([0-9]+))"\\s*:`, "g");
for (const match of generated.matchAll(entityPattern)) {
const entityId = match[1];
const npcId = Number(match[2]);
if (!Number.isSafeInteger(npcId) || npcId <= 0) throw new Error(`Invalid Runewaker NPC id in ${entityId}.`);
if (entityIdToNpcId[entityId] !== undefined && entityIdToNpcId[entityId] !== npcId) throw new Error(`Entity mapping collision for ${entityId}.`);
entityIdToNpcId[entityId] = npcId;
npcIds.add(npcId);
}
}
if (!npcIds.size) throw new Error("No generated population entity to NPC mappings were found.");
const npcs = [...npcIds].sort((a, b) => a - b).map(npcRow);
// Combat hit bindings are exported only for source rows already used by the
// native damage/periodic helpers. This preserves the bound skill/component
// pair without attempting to infer player hit rules from an ability label.
const playerDamageCatalog = await readJson(path.join(projectRoot, "src/game/generated/romNativeHolyHealBindings.generated.json"));
const playerPeriodicCatalog = await readJson(path.join(projectRoot, "src/game/generated/romNativePeriodicDamageBindings.generated.json"));
const playerSkillIds = new Set();
for (const ability of [...(playerDamageCatalog.attackBindings ?? []), ...(playerDamageCatalog.holyHealBindings ?? [])]) {
for (const rank of ability.ranks ?? []) if (Number.isSafeInteger(rank.sourceSkillId)) playerSkillIds.add(rank.sourceSkillId);
}
for (const ability of playerPeriodicCatalog.activeDamageBindings ?? []) {
for (const rank of ability.ranks ?? []) if (Number.isSafeInteger(rank.sourceSkillId)) playerSkillIds.add(rank.sourceSkillId);
}
const playerSpellBindings = [...playerSkillIds].sort((a, b) => a - b).map((sourceSkillId) => {
const binding = collectBinding(sourceSkillId);
return binding.missingSource ? null : { sourceSkillId, ...binding };
}).filter(Boolean);
const growthTable = (buffer, rowSize, count, expectedBase) => {
const rows = new Map();
for (let i = 0; i < count; i += 1) {
const offset = DATA_START + i * rowSize;
const id = int32(buffer, offset);
if (id < expectedBase) continue;
rows.set(id, Array.from({ length: 100 }, (_, index) => float32(buffer, offset + 8 + index * 4)));
}
return rows;
};
fixedRecords(vocDb, TABLE_SIZE, null, "VocationTable");
fixedRecords(raceDb, TABLE_SIZE, null, "RaceTable");
fixedRecords(sexDb, TABLE_SIZE, null, "SexTable");
fixedRecords(fixDb, TABLE_SIZE, null, "FixTable");
const vocCount = (vocDb.length - DATA_START) / TABLE_SIZE;
const raceCount = (raceDb.length - DATA_START) / TABLE_SIZE;
const sexCount = (sexDb.length - DATA_START) / TABLE_SIZE;
const fixCount = (fixDb.length - DATA_START) / TABLE_SIZE;
const vocRows = growthTable(vocDb, TABLE_SIZE, vocCount, 301000);
const raceRows = growthTable(raceDb, TABLE_SIZE, raceCount, 300000);
const sexRows = growthTable(sexDb, TABLE_SIZE, sexCount, 302000);
const fixRows = growthTable(fixDb, TABLE_SIZE, fixCount, 303000);
const growthFields = (values) => ({
growth: Object.fromEntries(STAT_NAMES.map((name, i) => [name, {
levelOne: values[i], flat: values[i + 7], percent: values[i + 14],
}])),
coefficients: Object.fromEntries(GROWTH_NAMES.slice(21).map((name, i) => [name, values[21 + i]])),
damageRate: values[43],
});
const npcClasses = Object.fromEntries(NPC_CLASS_IDS.map((id) => {
const values = vocRows.get(301000 + id * 2);
if (!values) throw new Error(`Missing NPC vocation row ${301000 + id * 2}.`);
return [id, { sourceRecordId: 301000 + id * 2, ...growthFields(values) }];
}));
const raceGrowth = Object.fromEntries([...raceRows].filter(([id]) => id >= 300000 && id <= 300031).map(([id, values]) => [id, growthFields(values)]));
const sexGrowth = Object.fromEntries([...sexRows].filter(([id]) => id >= 302000 && id <= 302004).map(([id, values]) => [id, growthFields(values)]));
const fix = (recordId) => {
const values = fixRows.get(recordId);
if (!values) throw new Error(`Missing FIX row ${recordId}.`);
return Object.fromEntries(FIX_FIELDS.map((name, i) => [name, values[i]]));
};
const skillBaseCount = int32(skillBaseDb, 132);
const jobSkillCount = int32(jobSkillDb, 132);
if (skillBaseCount !== 100 || jobSkillCount < 15) throw new Error("Skill table count drifted.");
const skillBaseRows = Array.from({ length: skillBaseCount }, (_, i) => {
const offset = DATA_START + i * 88;
if (int32(skillBaseDb, offset) !== 305000 + i) throw new Error(`SkillValueBase row ${i} drifted.`);
return Array.from({ length: 20 }, (_, j) => int32(skillBaseDb, offset + 8 + j * 4));
});
const jobSkillRows = Array.from({ length: jobSkillCount }, (_, i) => {
const offset = DATA_START + i * 136;
if (int32(jobSkillDb, offset) !== 306000 + i) throw new Error(`JobSkillValue row ${i} drifted.`);
return Array.from({ length: 32 }, (_, j) => int32(jobSkillDb, offset + 8 + j * 4));
});
const maxSkillByClass = Object.fromEntries(NPC_CLASS_IDS.map((classId) => [classId,
Array.from({ length: skillBaseCount }, (_, levelIndex) => Array.from({ length: 15 }, (_, skillId) => {
const skillType = jobSkillRows[skillId][classId];
if (!Number.isInteger(skillType) || skillType < 0 || skillType >= 20) throw new Error(`Invalid skill type ${skillType}.`);
return skillBaseRows[levelIndex][skillType];
})),
]));
const output = {
schemaVersion: 1,
source: {
npcObjectSha256: sha256(npcDb), magicCollectSha256: sha256(collectDb), magicObjectSha256: sha256(magicDb),
vocationTableSha256: sha256(vocDb), raceTableSha256: sha256(raceDb), sexTableSha256: sha256(sexDb),
fixTableSha256: sha256(fixDb), skillBaseTableSha256: sha256(skillBaseDb), jobSkillTableSha256: sha256(jobSkillDb),
npcRecordSize: NPC_SIZE, npcRaceOffset: NPC_RACE, npcSpellStartOffset: NPC_SPELL_START,
npcSpellStride: NPC_SPELL_SIZE, npcEquipmentOffsets: { mainHand: 412, offHand: 432 },
npcMagicResistanceOffset: NPC_RESIST_START,
npcFixedEquipmentOffsets: { bow: NPC_BOW_EQUIPMENT, ornament: NPC_ORNAMENT_EQUIPMENT },
npcClassFixTable: 0, playerFixTable: 1, playerSkillPolicy: "simulator-max-trained-by-source-tables",
playerBindingCatalogs: ["romNativeHolyHealBindings.generated.json", "romNativePeriodicDamageBindings.generated.json"],
},
sourceRules: {
npcClasses, raceGrowth, sexGrowth, npcFix: fix(303000), playerFix: fix(303001), maxSkillByClass,
npcLevelClamp: 200, growthFormulaMaxSourceLevel: 200, maxSkillTableLevel: skillBaseCount,
},
entityIdToNpcId,
npcs,
playerSpellBindings,
};
await writeFile(path.join(projectRoot, "src/game/generated/romNativeCombatData.generated.json"), `${JSON.stringify(output)}\n`);
console.log(`Exported ${npcs.length} NPC rows and ${Object.keys(entityIdToNpcId).length} exact population entity→NPC mappings.`);
@@ -0,0 +1,93 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../..");
const inputPath = path.join(projectRoot, "src", "game", "generated", "romHeirloomReference.json");
const outputPath = path.join(projectRoot, "src", "game", "generated", "romNativeDamageTrainingProfiles.generated.json");
const classes = ["rom-knight", "rom-priest", "rom-scout", "rom-rogue", "rom-mage"];
const slots = ["main-hand", "off-hand", "ranged"];
const sha256 = (buffer) => createHash("sha256").update(buffer).digest("hex");
const inputBuffer = await readFile(inputPath);
const catalog = JSON.parse(inputBuffer.toString("utf8"));
const selectedItemIds = new Set(Object.values(catalog.profiles).flatMap((profile) =>
Object.values(profile).flatMap((curve) => curve.flatMap((row) => row.referenceItemIds))));
const selectedItems = catalog.sourceItems.filter((item) => selectedItemIds.has(item.itemId));
const itemById = new Map(selectedItems.map((item) => [item.itemId, item]));
if (selectedItems.length !== selectedItemIds.size || selectedItems.some((item) =>
item.unmappedEffects?.length || item.unresolvedAttributeIds?.length)) {
throw new Error("Training reference has unresolved native body channels.");
}
const profiles = {};
for (const classId of classes) {
const sourceProfile = catalog.profiles?.[classId];
if (!sourceProfile) throw new Error(`Missing source training profile for ${classId}.`);
const classProfile = {};
for (const slot of slots) {
const curve = sourceProfile[slot];
if (!curve) continue;
if (curve.length !== 79) throw new Error(`${classId}/${slot} must cover levels 1–79.`);
let previousShieldDefense = 0;
classProfile[slot] = curve.map((entry, index) => {
const weaponDamage = entry.stats?.weaponDamage ?? 0;
const magicalWeaponDamage = entry.stats?.magicalWeaponDamage ?? 0;
const speedTenths = entry.speedTenths ?? null;
const referenceItems = entry.referenceItemIds.map((id) => itemById.get(id));
const weaponType = referenceItems.length && referenceItems.every((item) => item?.sourceCatalog === "weaponobject.db"
&& item.type === referenceItems[0].type) ? referenceItems[0].type : null;
const shields = referenceItems.length && referenceItems.every((item) => item?.sourceCatalog === "armorobject.db"
&& item.type === 5) ? [...referenceItems].sort((left, right) => left.level - right.level) : [];
if (shields.length) {
const level = index + 1;
const lower = shields[0];
const upper = shields.at(-1);
const blend = level < lower.level ? level / lower.level : level > upper.level ? level / upper.level
: lower.level === upper.level ? 0 : (level - lower.level) / (upper.level - lower.level);
const rawDefense = level < lower.level ? (upper.baseStats.physicalDefense ?? 0) * blend
: level > upper.level ? (lower.baseStats.physicalDefense ?? 0) * blend
: (lower.baseStats.physicalDefense ?? 0) * (1 - blend) + (upper.baseStats.physicalDefense ?? 0) * blend;
previousShieldDefense = Math.max(previousShieldDefense, rawDefense > 0 ? Math.max(1, Math.round(rawDefense)) : 0);
}
if (![weaponDamage, magicalWeaponDamage].every(Number.isFinite)
|| (speedTenths !== null && !Number.isFinite(speedTenths))) {
throw new Error(`Non-finite source training weapon row ${classId}/${slot}/${index + 1}.`);
}
return {
level: index + 1,
sourceItemIds: entry.referenceItemIds,
weaponDamage,
magicalWeaponDamage,
speedTenths,
weaponType,
...(shields.length ? { shieldDefense: previousShieldDefense } : {}),
};
});
}
profiles[classId] = classProfile;
}
const output = {
schemaVersion: 1,
status: "compact source-selected training weapon references; simulator gear profile and level interpolation policy",
source: {
file: "src/game/generated/romHeirloomReference.json",
sha256: sha256(inputBuffer),
sourceItemDatabases: catalog.sourceFiles,
quality: catalog.quality,
levelCap: 79,
selectedItemCount: selectedItems.length,
// Selection excludes unmapped effects, including typed MPow/Resist/AllRange.
// These are item contributions only; actor auras and racial/source base inputs remain separate.
trainingItemBodyDefaults: {
perSchoolMagicPowerRate: 1, perSchoolResistance: 0, perSchoolResistanceRate: 1,
allRangeDamageRate: 1, throwDamageRate: 1, throwingWeaponDamage: 0,
},
},
profiles,
};
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
console.log(`Wrote compact native training weapon profiles to ${path.relative(projectRoot, outputPath)}.`);
@@ -0,0 +1,249 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../..");
const sourceRoot = path.resolve(process.env.RUNEWAKER_ROOT ?? path.join(projectRoot, "..", "Runewaker"));
const dataRoot = path.join(sourceRoot, "Resource", "data");
const magicCollectPath = path.join(dataRoot, "magiccollectobject.db");
const magicObjectPath = path.join(dataRoot, "magicobject.db");
const magicProcessPath = path.join(sourceRoot, "SourceCodeVS2019", "Server", "NetGameServer", "MainProc", "MagicProc", "MagicProcess.cpp");
const objectStructPath = path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "ObjectStruct.h");
const periodicRankSourcePath = path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "RD_Normal.cxx");
const periodicTickSourcePath = path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "RD_ValueCal.cxx");
const catalogPath = path.join(projectRoot, "src", "game", "romPlayerCatalog.generated.json");
const nativeStatRulesPath = path.join(projectRoot, "src", "game", "generated", "romNativeStatRules.generated.json");
const outputPath = path.join(projectRoot, "src", "game", "generated", "romNativeHolyHealBindings.generated.json");
const DATA_START = 140;
const COUNT_OFFSET = 132;
const MAGIC_COLLECT_RECORD_SIZE = 1032;
const MAGIC_OBJECT_RECORD_SIZE = 992;
const HOLY_HEAL_CALCULATION_TYPE = 5;
const HEALING_FORMULA_SOURCE = {
orgPower: "DmgPower + DmgPower * DmgPower_SkillLVArg * MagicProc.MagicLv / 100",
spellTime: "MagicCol.SpellTime, replaced by 1 when less than 1 or IsFixDmgEffect is set",
mind: "MND * 0.3 * SpellTime; if OrgPower * 5 < this value use OrgPower * 6, else add this value",
mdmg: "unless IsIgnoreDmgEffect, add MDMG * FIX.Dmg_HealArg once for fixed effects or multiplied by SpellTime otherwise",
scale: "multiply by MPow * HealPowerRate, then add HealPoint * SpellTime",
target: "unless MagicBase.SettingEx.IgnoreMagicAbsorb, multiply by 1 + HealAbsorbRate / 100 then add HealAbsorb; clamp below zero; undead reverses sign",
random: "Power *= 1 - (Atk.Rand - Atk.Rand * 2 * rand() / 0x7fff) / 100",
periodicHeal: "RD_Normal::CalAddDotMagicLv snapshots rank from positive magic HP DotMagic: DotTimes=max(1,int(actual EffectTime/Dot.Time)); DotBase=Base*(1+MagicLv*DotArg/100); Power=DotBase+ExRate*MDMG*int(SpellTime)*FIX.Dmg_HealDotArg/DotTimes; then MND cap/add, HealPowerRate and HealPoint*SpellTime/DotTimes, target absorb rate and absorb/DotTimes unless ignored, clamp below zero, then MagicPow(MagicType); buff Power is that helper's correction plus MagicLv. RD_ValueCal::BuffProc tick is int(-Base*(100+DotArg*buff.Power)/100), then AddHP(-Damage).",
};
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
function int32(buffer, offset) {
return buffer.readInt32LE(offset);
}
function number32(buffer, offset) {
const value = buffer.readFloatLE(offset);
if (!Number.isFinite(value)) throw new Error(`Non-finite source float at 0x${offset.toString(16)}.`);
return Number(value.toFixed(6));
}
function records(buffer, recordSize, expectedType, label) {
const count = int32(buffer, COUNT_OFFSET);
if (count <= 0 || DATA_START + count * recordSize > buffer.length) {
throw new Error(`${label} record count or record size does not match this exporter.`);
}
for (const index of [0, Math.floor(count / 2), count - 1]) {
if (int32(buffer, DATA_START + index * recordSize + 4) !== expectedType) {
throw new Error(`${label} type sentinel does not match this exporter.`);
}
}
return Array.from({ length: count }, (_, index) => DATA_START + index * recordSize);
}
const [magicCollect, magicObject, magicProcess, objectStruct, periodicRankSource, periodicTickSource, catalogBuffer, nativeStatRulesBuffer] = await Promise.all([
readFile(magicCollectPath),
readFile(magicObjectPath),
readFile(magicProcessPath),
readFile(objectStructPath),
readFile(periodicRankSourcePath),
readFile(periodicTickSourcePath),
readFile(catalogPath),
readFile(nativeStatRulesPath),
]);
const catalog = JSON.parse(catalogBuffer.toString("utf8"));
const nativeStatRules = JSON.parse(nativeStatRulesBuffer.toString("utf8"));
const collectOffsets = new Map(records(magicCollect, MAGIC_COLLECT_RECORD_SIZE, 21, "magiccollectobject.db")
.map((offset) => [int32(magicCollect, offset), offset]));
const objectOffsets = new Map(records(magicObject, MAGIC_OBJECT_RECORD_SIZE, 3, "magicobject.db")
.map((offset) => [int32(magicObject, offset), offset]));
function rawComponents(collectOffset) {
const componentIds = new Set();
for (let offset = 0x180; offset <= 0x2c0; offset += 4) {
const id = int32(magicCollect, collectOffset + offset);
if (objectOffsets.has(id)) componentIds.add(id);
}
return [...componentIds].sort((left, right) => left - right);
}
function sourceComponent(componentId) {
const offset = objectOffsets.get(componentId);
if (offset === undefined) return null;
const attackType = int32(magicObject, offset + 0x194);
const calculationType = int32(magicObject, offset + 0x2d4);
const settingFlags = int32(magicObject, offset + 0xd8);
// GameObjectMagicStruct ends with SettingEx (ObjectStruct.h:4131-4132).
const settingExFlags = int32(magicObject, offset + MAGIC_OBJECT_RECORD_SIZE - 4);
return {
componentId,
magicFunc: int32(magicObject, offset + 0xb0),
magicType: int32(magicObject, offset + 0xb4),
effectType: int32(magicObject, offset + 0xb8),
effectTimeSeconds: number32(magicObject, offset + 0xbc),
levelDeltaEffectTime: number32(magicObject, offset + 0xc0),
effectTimeSkillLevelArg: number32(magicObject, offset + 0xc4),
settingFlags,
isDotMagic: (settingFlags & (1 << 3)) !== 0,
isFixDotPower: (settingFlags & (1 << 20)) !== 0,
dotTimeSeconds: int32(magicObject, offset + 0x184),
dotType: int32(magicObject, offset + 0x188),
dotBase: int32(magicObject, offset + 0x18c),
dotSkillLevelArg: number32(magicObject, offset + 0x190),
attackType,
calculationType,
damagePower: number32(magicObject, offset + 0x198),
damagePowerSkillLevelArg: number32(magicObject, offset + 0x19c),
fixedValue: number32(magicObject, offset + 0x1a0),
fixedType: int32(magicObject, offset + 0x1a4),
fixedDamageSkillLevelArg: number32(magicObject, offset + 0x1a8),
randomRange: number32(magicObject, offset + 0x1ac),
criticalRate: int32(magicObject, offset + 0x1b0),
ignoreMagicAbsorb: (settingExFlags & 1) !== 0,
rawSettingExFlags: settingExFlags,
};
}
const attackBindings = [];
const periodicHealBindings = [];
for (const skill of catalog.skills ?? []) {
const ranks = skill.ranks?.length ? skill.ranks : [{ level: skill.unlockLevel, sourceSkillId: skill.sourceSkillId }];
const directRankRows = [];
const dotHealRankRows = [];
for (const rank of ranks) {
const sourceSkillId = Number(rank.sourceSkillId ?? skill.sourceSkillId);
const collectOffset = collectOffsets.get(sourceSkillId);
if (collectOffset === undefined) continue;
const collectRefs = rawComponents(collectOffset);
const collectFlags = int32(magicCollect, collectOffset + 0x110);
const collectMode = int32(magicCollect, collectOffset + 0x368);
const rankRow = {
rank: rank.rank ?? null,
unlockLevel: rank.level ?? skill.unlockLevel,
sourceSkillId,
configuredCollectMagicLevel: int32(magicCollect, collectOffset + 0xb0),
spellTimeSeconds: number32(magicCollect, collectOffset + 0x114),
nextSpellTimeSeconds: number32(magicCollect, collectOffset + 0x120),
spellCount: int32(magicCollect, collectOffset + 0x11c),
rangeType: int32(magicCollect, collectOffset + 0xc4),
decreaseArg: int32(magicCollect, collectOffset + 0xd0),
rawCollectTimingFlags: collectFlags,
referenceWeaponBowSpellTime: (collectFlags & (1 << 13)) !== 0,
referenceWeaponThrowSpellTime: (collectFlags & (1 << 14)) !== 0,
rawCollectMode: collectMode,
isIgnoreDmgEffect: (collectMode & (1 << 15)) !== 0,
isFixDmgEffect: (collectMode & (1 << 16)) !== 0,
isShareDamage: (collectMode & (1 << 19)) !== 0,
};
const directComponents = [];
const dotHealComponents = [];
for (const componentId of collectRefs) {
const component = sourceComponent(componentId);
if (!component) continue;
if (component.magicFunc === 0 && component.attackType === 0) directComponents.push(component);
if (component.isDotMagic && component.effectType === 0 && component.dotType === 0
&& component.dotTimeSeconds > 0 && component.dotBase > 0) {
dotHealComponents.push(component);
}
}
if (directComponents.length) directRankRows.push({ ...rankRow, componentIds: directComponents });
if (dotHealComponents.length) dotHealRankRows.push({ ...rankRow, componentIds: dotHealComponents });
}
const skillBase = {
abilityId: skill.id,
classId: skill.classId,
...(skill.secondaryClassId ? { secondaryClassId: skill.secondaryClassId } : {}),
group: skill.group,
unlockLevel: skill.unlockLevel,
name: skill.name,
};
if (directRankRows.length) attackBindings.push({ ...skillBase, ranks: directRankRows });
if (dotHealRankRows.length) periodicHealBindings.push({ ...skillBase, ranks: dotHealRankRows });
}
const output = {
schemaVersion: 1,
source: {
magicCollectDatabase: "Resource/data/magiccollectobject.db",
magicCollectSha256: sha256(magicCollect),
magicObjectDatabase: "Resource/data/magicobject.db",
magicObjectSha256: sha256(magicObject),
magicProcessSource: "SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp",
magicProcessSha256: sha256(magicProcess),
objectStructSource: "SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h",
objectStructSha256: sha256(objectStruct),
periodicRankSource: "SourceCodeVS2019/Libraries/Union/RoleData/RD_Normal.cxx",
periodicRankSourceSha256: sha256(periodicRankSource),
periodicTickSource: "SourceCodeVS2019/Libraries/Union/RoleData/RD_ValueCal.cxx",
periodicTickSourceSha256: sha256(periodicTickSource),
playerCatalog: "src/game/romPlayerCatalog.generated.json",
playerCatalogSha256: sha256(catalogBuffer),
nativeStatRules: "src/game/generated/romNativeStatRules.generated.json",
nativeStatRulesSha256: sha256(nativeStatRulesBuffer),
},
recordLayout: {
magicCollect: { offset: DATA_START, recordSize: MAGIC_COLLECT_RECORD_SIZE, type: 21, magicColBaseOffset: 0xb0 },
magicObject: { offset: DATA_START, recordSize: MAGIC_OBJECT_RECORD_SIZE, type: 3 },
magicCollectSpellTimeOffset: 0x114,
magicCollectFlagsOffset: 0x110,
magicCollectTimingFlagsBowSpellTimeBit: 13,
magicCollectTimingFlagsThrowSpellTimeBit: 14,
magicObjectAttackPowerOffset: 0x198,
magicObjectAttackPowerSkillLevelArgOffset: 0x19c,
magicObjectAttackCalculationTypeOffset: 0x2d4,
magicObjectSettingFlagsOffset: 0xd8,
magicObjectDotMagicEnabledBit: 3,
magicObjectFixDotPowerBit: 20,
magicObjectEffectTimeOffset: 0xbc,
magicObjectLevelDeltaEffectTimeOffset: 0xc0,
magicObjectEffectTimeSkillLevelArgOffset: 0xc4,
magicObjectDotTimeOffset: 0x184,
magicObjectDotTypeOffset: 0x188,
magicObjectDotBaseOffset: 0x18c,
magicObjectDotSkillLevelArgOffset: 0x190,
magicObjectSettingExFlagsOffset: MAGIC_OBJECT_RECORD_SIZE - 4,
magicObjectIgnoreMagicAbsorbBit: 0,
magicCollectModeOffset: 0x368,
magicCollectModeIgnoreDamageBit: 15,
magicCollectModeFixedDamageBit: 16,
magicCollectModeShareDamageBit: 19,
},
formula: {
classification: "source-backed local RuneWaker HolyHeal resolver; no Chronicles of Arcadia values",
magicLevel: "MagicProc.MagicLv is a required runtime input; the source catalog does not supply a universal rank scalar",
fixHealMagicDamageCoefficient: 0.5,
fixHealingDotMagicDamageCoefficient: nativeStatRules.playerFix.healingDotMagicalDamageCoefficient,
periodicMagicLevel: "CalAddDotMagicLv returns 0 when FixDotPower is set or DotSkillLVArg is 0; otherwise the caster computes a source correction and adds it to MagicProc.MagicLv at buff application. The tick helper consumes that frozen Buff.Power.",
periodicSourceActorPolicy: "Runewaker uses its player ExRate only for IsPlayer() owners; this simulator treats both active player and player-build party identities as source-player healing owners.",
expressions: HEALING_FORMULA_SOURCE,
},
periodicHealBindings,
attackBindings,
holyHealBindings: attackBindings.flatMap((ability) => ability.ranks.flatMap((rank) => {
const components = rank.componentIds.filter((component) => component.calculationType === HOLY_HEAL_CALCULATION_TYPE);
return components.length ? [{ ...ability, ranks: [{ ...rank, componentIds: components }] }] : [];
})),
};
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
console.log(`Wrote ${attackBindings.length} source attack bindings, ${output.holyHealBindings.length} HolyHeal bindings, and ${periodicHealBindings.length} positive HP HoT bindings to ${path.relative(projectRoot, outputPath)}.`);
@@ -0,0 +1,224 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { projectRoot, readJson, sourceRootFor } from "./lib/recipe.mjs";
const DATA_START = 140;
const NPC_RECORD_SIZE = 2224;
const ITEM_RECORD_SIZE = 1776;
const REFINE_RECORD_SIZE = 280;
const NPC_OBJECT_TYPE = 1;
const ITEM_OBJECT_TYPE = 2;
const REFINE_OBJECT_TYPE = 22;
const NPC_EQUIPMENT = Object.freeze({
mainHand: { start: 412, count: 5 },
offHand: { start: 432, count: 5 },
bow: { start: 1260, count: 1 },
ornament: { start: 1648, count: 1 },
});
const ITEM_FIELDS = Object.freeze({
durable: 0x54,
quality: 0x60,
level: 0x78,
gearType: 0xd0,
gearPosition: 0xd4,
eqTypes: 0xd8,
eqValues: 0x100,
dropAbilities: 0x134,
attackRange: 0x1ac,
attackSpeed: 0x1b0,
refineTableId: 0x3d0,
refineLevel: ITEM_RECORD_SIZE - 12,
addPowerQuality: ITEM_RECORD_SIZE - 8,
collectionNpc: ITEM_RECORD_SIZE - 4,
});
const REFINE_FIELDS = Object.freeze({
type: 0xb4,
level: 0xb8,
eqTypes: 0xbc,
eqValues: 0xe4,
exEqPowerRate: 0x114,
});
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
const int32 = (bytes, offset) => bytes.readInt32LE(offset);
const checkTable = (bytes, recordSize, objectType, label) => {
const count = int32(bytes, 132);
if (!Number.isInteger(count) || count <= 0 || DATA_START + count * recordSize > bytes.length) {
throw new Error(`${label} fixed-record layout changed.`);
}
for (const index of [0, Math.floor(count / 2), count - 1]) {
if (int32(bytes, DATA_START + index * recordSize + 4) !== objectType) {
throw new Error(`${label} object type sentinel changed.`);
}
}
return count;
};
const recipeDirectory = path.join(projectRoot, "scripts/runewaker-pipeline/recipes");
const recipes = (await readdir(recipeDirectory)).filter((name) => name.endsWith("-population.json")).sort();
if (!recipes.length) throw new Error("No population recipes found.");
const recipe = await readJson(path.join(recipeDirectory, recipes[0]));
const sourceRoot = sourceRootFor(recipe);
const dataRoot = path.join(sourceRoot, recipe.source.resourceRoot ?? "Resource", "data");
const filenames = ["npcobject.db", "armorobject.db", "weaponobject.db", "eqrefineabilityobject.db"];
const buffers = Object.fromEntries(await Promise.all(filenames.map(async (filename) => (
[filename, await readFile(path.join(dataRoot, filename))]
))));
const npcCount = checkTable(buffers["npcobject.db"], NPC_RECORD_SIZE, NPC_OBJECT_TYPE, "NPCObjectDB");
const itemCounts = Object.fromEntries(["armorobject.db", "weaponobject.db"].map((filename) => (
[filename, checkTable(buffers[filename], ITEM_RECORD_SIZE, ITEM_OBJECT_TYPE, filename)]
)));
const refineCount = checkTable(buffers["eqrefineabilityobject.db"], REFINE_RECORD_SIZE, REFINE_OBJECT_TYPE, "EqRefineAbilityObjectDB");
const nativeCombatData = await readJson(path.join(projectRoot, "src/game/generated/romNativeCombatData.generated.json"));
if (!Array.isArray(nativeCombatData.npcs) || nativeCombatData.npcs.length === 0) {
throw new Error("Native combat NPC catalog is empty.");
}
const npcSourceOffsets = new Map();
for (let index = 0; index < npcCount; index += 1) {
const offset = DATA_START + index * NPC_RECORD_SIZE;
if (int32(buffers["npcobject.db"], offset + 4) !== NPC_OBJECT_TYPE) continue;
const npcId = int32(buffers["npcobject.db"], offset);
if (npcId > 0) npcSourceOffsets.set(npcId, offset);
}
const sourceItemsById = new Map();
for (const filename of ["armorobject.db", "weaponobject.db"]) {
const bytes = buffers[filename];
for (let index = 0; index < itemCounts[filename]; index += 1) {
const offset = DATA_START + index * ITEM_RECORD_SIZE;
if (int32(bytes, offset + 4) !== ITEM_OBJECT_TYPE) throw new Error(`${filename} object type changed at ${index}.`);
const itemId = int32(bytes, offset);
if (itemId <= 0) continue;
if (sourceItemsById.has(itemId)) throw new Error(`Duplicate source item ID ${itemId} across equipment catalogs.`);
const eqPairs = Array.from({ length: 10 }, (_, slot) => ({
slot,
eqType: int32(bytes, offset + ITEM_FIELDS.eqTypes + slot * 4),
value: int32(bytes, offset + ITEM_FIELDS.eqValues + slot * 4),
}));
const dropAbilityPairs = Array.from({ length: 6 }, (_, slot) => ({
slot,
attributeId: int32(bytes, offset + ITEM_FIELDS.dropAbilities + slot * 8),
rate: int32(bytes, offset + ITEM_FIELDS.dropAbilities + slot * 8 + 4),
}));
sourceItemsById.set(itemId, {
sourceItemId: itemId,
sourceCatalog: filename,
sourceRecordIndex: index,
objectKind: "equipment-item",
objectType: int32(bytes, offset + 4),
quality: int32(bytes, offset + ITEM_FIELDS.quality),
itemLevel: int32(bytes, offset + ITEM_FIELDS.level),
gearType: int32(bytes, offset + ITEM_FIELDS.gearType),
gearPosition: int32(bytes, offset + ITEM_FIELDS.gearPosition),
eqTypeValuePairs: eqPairs,
dropAbilityIdRatePairs: dropAbilityPairs,
durable: int32(bytes, offset + ITEM_FIELDS.durable),
refineTableId: int32(bytes, offset + ITEM_FIELDS.refineTableId),
attackRange: filename === "weaponobject.db" ? int32(bytes, offset + ITEM_FIELDS.attackRange) : null,
attackSpeed: filename === "weaponobject.db" ? int32(bytes, offset + ITEM_FIELDS.attackSpeed) : null,
refineLevel: int32(bytes, offset + ITEM_FIELDS.refineLevel),
addPowerQuality: int32(bytes, offset + ITEM_FIELDS.addPowerQuality),
collectionNpcId: int32(bytes, offset + ITEM_FIELDS.collectionNpc),
});
}
}
const referencedItemIds = new Set();
const npcs = nativeCombatData.npcs.map((catalogNpc) => {
const offset = npcSourceOffsets.get(catalogNpc.npcId);
if (offset === undefined) throw new Error(`Missing NPCObjectDB row ${catalogNpc.npcId}.`);
const bytes = buffers["npcobject.db"];
const candidates = (slot) => Array.from({ length: slot.count }, (_, candidateIndex) => {
const sourceItemId = int32(bytes, offset + slot.start + candidateIndex * 4);
if (sourceItemId > 0) referencedItemIds.add(sourceItemId);
return { candidateIndex, sourceItemId, selectionWeightPercent: 20 };
});
const bowEquipmentId = int32(bytes, offset + NPC_EQUIPMENT.bow.start);
const ornamentEquipmentId = int32(bytes, offset + NPC_EQUIPMENT.ornament.start);
if (bowEquipmentId > 0) referencedItemIds.add(bowEquipmentId);
if (ornamentEquipmentId > 0) referencedItemIds.add(ornamentEquipmentId);
return {
npcId: catalogNpc.npcId,
sourceRecordIndex: (offset - DATA_START) / NPC_RECORD_SIZE,
mainHandCandidates: candidates(NPC_EQUIPMENT.mainHand),
offHandCandidates: candidates(NPC_EQUIPMENT.offHand),
bowEquipmentId,
ornamentEquipmentId,
};
});
const referencedRefineIds = new Set();
for (const itemId of referencedItemIds) {
const item = sourceItemsById.get(itemId);
if (!item || item.refineLevel <= 0) continue;
const level = Math.min(item.refineLevel, 20);
for (let refineLevel = 1; refineLevel <= level; refineLevel += 1) {
referencedRefineIds.add(item.refineTableId + refineLevel - 1);
}
}
const refineById = new Map();
const refineBytes = buffers["eqrefineabilityobject.db"];
for (let index = 0; index < refineCount; index += 1) {
const offset = DATA_START + index * REFINE_RECORD_SIZE;
if (int32(refineBytes, offset + 4) !== REFINE_OBJECT_TYPE) {
throw new Error(`eqrefineabilityobject.db object type changed at ${index}.`);
}
const id = int32(refineBytes, offset);
if (!referencedRefineIds.has(id)) continue;
refineById.set(id, {
refineObjectId: id,
sourceRecordIndex: index,
refineType: int32(refineBytes, offset + REFINE_FIELDS.type),
refineLevel: int32(refineBytes, offset + REFINE_FIELDS.level),
eqTypeValuePairs: Array.from({ length: 10 }, (_, slot) => ({
slot,
eqType: int32(refineBytes, offset + REFINE_FIELDS.eqTypes + slot * 4),
value: int32(refineBytes, offset + REFINE_FIELDS.eqValues + slot * 4),
})),
exEqPowerRate: int32(refineBytes, offset + REFINE_FIELDS.exEqPowerRate),
});
}
const refineItemsById = Object.fromEntries([...referencedRefineIds].sort((left, right) => left - right)
.map((id) => [id, refineById.get(id) ?? null]));
const unresolvedRefineIds = Object.entries(refineItemsById).filter(([, item]) => item === null).map(([id]) => Number(id));
const itemsById = {};
for (const itemId of [...referencedItemIds].sort((left, right) => left - right)) {
itemsById[itemId] = sourceItemsById.get(itemId) ?? null;
}
const unresolvedItemIds = Object.entries(itemsById).filter(([, item]) => item === null).map(([id]) => Number(id));
const output = {
schemaVersion: 1,
source: {
root: path.relative(projectRoot, sourceRoot).split(path.sep).join("/"),
files: Object.fromEntries(filenames.map((filename) => [filename, {
sha256: sha256(buffers[filename]),
recordStart: DATA_START,
recordSize: filename === "npcobject.db" ? NPC_RECORD_SIZE
: filename === "eqrefineabilityobject.db" ? REFINE_RECORD_SIZE : ITEM_RECORD_SIZE,
recordCount: filename === "npcobject.db" ? npcCount
: filename === "eqrefineabilityobject.db" ? refineCount : itemCounts[filename],
}])),
npcEquipmentOffsets: NPC_EQUIPMENT,
itemFieldOffsets: ITEM_FIELDS,
refineRecordSize: REFINE_RECORD_SIZE,
refineFieldOffsets: REFINE_FIELDS,
npcWearDefaults: { fieldDurable: 10000, fieldQuality: 100, fieldPowerQuality: 10, fieldRare: 0, inherentIds: [], runeIds: [] },
candidateRule: "RD_Setting.cxx selects MainHandEQ and SecHandEQ with rand() % 5; preserve every slot and duplicate at 20 percent each.",
simulatorSelectionPolicy: "Stable weighted selection over the source candidate slots using a caller-provided actor seed (or npcId default); each preserved 20-percent slot remains an independent source outcome and no item-stat averaging is performed.",
cxxSource: "Runewaker/SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h:GameObjectNpcStruct",
trailingItemFields: "ObjectStruct.h GameObjectItemStruct ends with RefineLv, AddPowerQuality, CollectionNPC; each is int32, so the 1776-byte fixed item row maps them to offsets 1764, 1768, 1772 respectively.",
itemOffset134Meaning: "GameObjectItemStruct::DropItemAbility[] (drop metadata, not equipment affixes). NPC spawn initialization does not populate runtime ItemFieldStruct inherent or rune slots.",
refineSource: "RoleDataEx::WearEQ reads RefineLv (capped at 20) and RoleDataEx::GetRefinAblility resolves Item.RefineTableID + level - 1.",
unresolvedItemIds,
unresolvedRefineIds,
},
npcs,
itemsById,
refineItemsById,
};
const outputPath = path.join(projectRoot, "src/game/generated/romNativeNpcEquipment.generated.json");
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
console.log(`Wrote ${npcs.length} NPC rows and ${Object.keys(itemsById).length} referenced items to ${path.relative(projectRoot, outputPath)}.`);
@@ -0,0 +1,335 @@
#!/usr/bin/env node
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDirectory = path.dirname(fileURLToPath(import.meta.url));
const projectRoot = path.resolve(scriptDirectory, "../..");
const sourceRoot = path.resolve(process.env.RUNEWAKER_ROOT ?? path.join(projectRoot, "..", "Runewaker"));
const dataRoot = path.join(sourceRoot, "Resource", "data");
const magicCollectPath = path.join(dataRoot, "magiccollectobject.db");
const magicObjectPath = path.join(dataRoot, "magicobject.db");
const magicProcessPath = path.join(sourceRoot, "SourceCodeVS2019", "Server", "NetGameServer", "MainProc", "MagicProc", "MagicProcess.cpp");
const objectStructPath = path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "ObjectStruct.h");
const periodicRankSourcePath = path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "RD_Normal.cxx");
const periodicTickSourcePath = path.join(sourceRoot, "SourceCodeVS2019", "Libraries", "Union", "RoleData", "RD_ValueCal.cxx");
const catalogPath = path.join(projectRoot, "src", "game", "romPlayerCatalog.generated.json");
const nativeStatRulesPath = path.join(projectRoot, "src", "game", "generated", "romNativeStatRules.generated.json");
const nativeCombatDataPath = path.join(projectRoot, "src", "game", "generated", "romNativeCombatData.generated.json");
const outputPath = path.join(projectRoot, "src", "game", "generated", "romNativePeriodicDamageBindings.generated.json");
const DATA_START = 140;
const COUNT_OFFSET = 132;
const MAGIC_COLLECT_RECORD_SIZE = 1032;
const MAGIC_OBJECT_RECORD_SIZE = 992;
const ACTIVE_PAIRS = Object.freeze({
"rom-knight": "rom-scout",
"rom-priest": "rom-knight",
"rom-rogue": "rom-warrior",
"rom-scout": "rom-rogue",
"rom-mage": "rom-warlock",
});
const COMPONENT_FILTER = Object.freeze({
dotMagicFlagBit: 3,
showPowerLevelBit: 14,
dotMagicNoKillBit: 15,
maxMagicLevelTargetBoundBit: 17,
fixDotPowerBit: 20,
ignoreMagicAbsorbBit: 0,
healthDotType: 0,
percentHealthDotType: 5,
});
const NATIVE_NPC_GROWTH_LEVEL_MAX = 200;
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
}
function int32(buffer, offset) {
return buffer.readInt32LE(offset);
}
function number32(buffer, offset) {
const value = buffer.readFloatLE(offset);
if (!Number.isFinite(value)) throw new Error(`Non-finite source float at 0x${offset.toString(16)}.`);
return Number(value.toFixed(6));
}
function records(buffer, recordSize, expectedType, label) {
const count = int32(buffer, COUNT_OFFSET);
if (count <= 0 || DATA_START + count * recordSize > buffer.length) {
throw new Error(`${label} record count or record size does not match this exporter.`);
}
for (const index of [0, Math.floor(count / 2), count - 1]) {
if (int32(buffer, DATA_START + index * recordSize + 4) !== expectedType) {
throw new Error(`${label} type sentinel does not match this exporter.`);
}
}
return Array.from({ length: count }, (_, index) => DATA_START + index * recordSize);
}
const [magicCollect, magicObject, magicProcess, objectStruct, periodicRankSource, periodicTickSource, catalogBuffer, nativeStatRulesBuffer, nativeCombatDataBuffer] = await Promise.all([
readFile(magicCollectPath),
readFile(magicObjectPath),
readFile(magicProcessPath),
readFile(objectStructPath),
readFile(periodicRankSourcePath),
readFile(periodicTickSourcePath),
readFile(catalogPath),
readFile(nativeStatRulesPath),
readFile(nativeCombatDataPath),
]);
const catalog = JSON.parse(catalogBuffer.toString("utf8"));
const nativeStatRules = JSON.parse(nativeStatRulesBuffer.toString("utf8"));
const nativeCombatData = JSON.parse(nativeCombatDataBuffer.toString("utf8"));
const collectOffsets = new Map(records(magicCollect, MAGIC_COLLECT_RECORD_SIZE, 21, "magiccollectobject.db")
.map((offset) => [int32(magicCollect, offset), offset]));
const objectOffsets = new Map(records(magicObject, MAGIC_OBJECT_RECORD_SIZE, 3, "magicobject.db")
.map((offset) => [int32(magicObject, offset), offset]));
function rawComponents(collectOffset) {
const componentIds = new Set();
for (let offset = 0x180; offset <= 0x2c0; offset += 4) {
const id = int32(magicCollect, collectOffset + offset);
if (objectOffsets.has(id)) componentIds.add(id);
}
return [...componentIds].sort((left, right) => left - right);
}
function sourceDotComponent(componentId) {
const offset = objectOffsets.get(componentId);
if (offset === undefined) return null;
const settingFlags = int32(magicObject, offset + 0xd8);
const settingExFlags = int32(magicObject, offset + MAGIC_OBJECT_RECORD_SIZE - 4);
const dotType = int32(magicObject, offset + 0x188);
const dotBase = int32(magicObject, offset + 0x18c);
const dotTimeSeconds = int32(magicObject, offset + 0x184);
if ((settingFlags & (1 << COMPONENT_FILTER.dotMagicFlagBit)) === 0
|| dotTimeSeconds <= 0 || dotBase >= 0
|| ![COMPONENT_FILTER.healthDotType, COMPONENT_FILTER.percentHealthDotType].includes(dotType)) return null;
return {
kind: "dot",
componentId,
magicFunc: int32(magicObject, offset + 0xb0),
magicType: int32(magicObject, offset + 0xb4),
effectType: int32(magicObject, offset + 0xb8),
effectTimeSeconds: number32(magicObject, offset + 0xbc),
levelDeltaEffectTime: number32(magicObject, offset + 0xc0),
effectTimeSkillLevelArg: number32(magicObject, offset + 0xc4),
maxBuffLevelBase: int32(magicObject, offset + 0xcc),
maxBuffLevelSkillArg: number32(magicObject, offset + 0xd0),
settingFlags,
isDotMagic: true,
isShowPowerLevel: (settingFlags & (1 << COMPONENT_FILTER.showPowerLevelBit)) !== 0,
isDotMagicNoKill: (settingFlags & (1 << COMPONENT_FILTER.dotMagicNoKillBit)) !== 0,
isMaxMagicLevelTargetBound: (settingFlags & (1 << COMPONENT_FILTER.maxMagicLevelTargetBoundBit)) !== 0,
isFixDotPower: (settingFlags & (1 << COMPONENT_FILTER.fixDotPowerBit)) !== 0,
dotTimeSeconds,
dotType,
dotBase,
dotSkillLevelArg: number32(magicObject, offset + 0x190),
buffMaxLevel: int32(magicObject, offset + 0x3b4),
isIgnoreMagicAbsorb: (settingExFlags & (1 << COMPONENT_FILTER.ignoreMagicAbsorbBit)) !== 0,
rawSettingExFlags: settingExFlags,
};
}
const activeDamageBindings = [];
for (const skill of catalog.skills ?? []) {
const expectedSecondary = ACTIVE_PAIRS[skill.classId];
if (!expectedSecondary || (skill.secondaryClassId && skill.secondaryClassId !== expectedSecondary)) continue;
const ranks = skill.ranks?.length ? skill.ranks : [{ level: skill.unlockLevel, sourceSkillId: skill.sourceSkillId }];
const dotRanks = [];
for (const rank of ranks) {
const sourceSkillId = Number(rank.sourceSkillId ?? skill.sourceSkillId);
const collectOffset = collectOffsets.get(sourceSkillId);
if (collectOffset === undefined) continue;
const components = rawComponents(collectOffset).map(sourceDotComponent).filter(Boolean);
if (!components.length) continue;
const collectMode = int32(magicCollect, collectOffset + 0x368);
dotRanks.push({
rank: rank.rank ?? null,
unlockLevel: rank.level ?? skill.unlockLevel,
sourceSkillId,
configuredCollectMagicLevel: int32(magicCollect, collectOffset + 0xb0),
rawSpellTimeSeconds: number32(magicCollect, collectOffset + 0x114),
rawCollectMode: collectMode,
isFixDmgEffect: (collectMode & (1 << 16)) !== 0,
componentIds: components,
});
}
if (!dotRanks.length) continue;
activeDamageBindings.push({
abilityId: skill.id,
classId: skill.classId,
...(skill.secondaryClassId ? { secondaryClassId: skill.secondaryClassId } : {}),
group: skill.group,
unlockLevel: skill.unlockLevel,
semanticKind: "dot",
sourceSkillIds: dotRanks.map((rank) => rank.sourceSkillId),
ranks: dotRanks,
});
}
function importedEnemyDotComponent(component) {
if (component.magicFunc !== 1 || component.isDotMagic !== true || component.dotTimeSeconds <= 0
|| component.dotBase >= 0 || ![COMPONENT_FILTER.healthDotType, COMPONENT_FILTER.percentHealthDotType].includes(component.dotType)) {
return null;
}
return {
kind: "dot",
componentId: component.componentId,
magicFunc: component.magicFunc,
magicType: component.magicType,
effectType: component.effectType,
effectTimeSeconds: component.effectTimeSeconds,
levelDeltaEffectTime: component.levelDeltaEffectTime,
effectTimeSkillLevelArg: component.effectTimeSkillLevelArg,
maxBuffLevelBase: component.maxBuffLevelBase,
maxBuffLevelSkillArg: component.maxBuffLevelSkillArg,
settingFlags: component.settingFlags,
isDotMagic: true,
isShowPowerLevel: component.isShowPowerLv,
isDotMagicNoKill: component.isNoKill,
isMaxMagicLevelTargetBound: component.isTargetBound,
isFixDotPower: component.isFixDotPower,
dotTimeSeconds: component.dotTimeSeconds,
dotType: component.dotType,
dotBase: component.dotBase,
dotSkillLevelArg: component.dotSkillLevelArg,
buffMaxLevel: component.buffMaxLevel,
isIgnoreMagicAbsorb: component.ignoreMagicAbsorb,
rawSettingExFlags: component.rawSettingExFlags,
};
}
const enemyDamageBindings = [];
for (const npc of nativeCombatData.npcs ?? []) {
const npcId = Number(npc.npcId);
const level = Number(npc.level);
const maxCasterLevel = NATIVE_NPC_GROWTH_LEVEL_MAX;
if (!Number.isSafeInteger(npcId) || npcId <= 0 || !Number.isSafeInteger(maxCasterLevel) || maxCasterLevel < 1) continue;
const ranks = [];
for (const spell of npc.spellBindings ?? []) {
const sourceSkillId = Number(spell.sourceSkillId);
const collect = spell.magicCollect ?? {};
const maxMagicLevel = Number(spell.magicLevel);
const componentIds = (spell.components ?? []).map(importedEnemyDotComponent).filter(Boolean);
if (!Number.isSafeInteger(sourceSkillId) || !Number.isSafeInteger(maxMagicLevel)
|| !Number.isFinite(Number(collect.spellTimeSeconds)) || componentIds.length === 0) continue;
ranks.push({
rank: null,
unlockLevel: level,
sourceSkillId,
maxCasterLevel,
maxMagicLevel,
configuredCollectMagicLevel: maxMagicLevel,
rawSpellTimeSeconds: Number(collect.spellTimeSeconds),
rawCollectMode: Number(collect.rawCollectMode),
rawMagicCollectSettingExFlags: Number(collect.rawSettingExFlags),
isFixDmgEffect: collect.isFixDmgEffect === true,
componentIds,
});
}
if (!ranks.length) continue;
enemyDamageBindings.push({
abilityId: `rom-npc-${npcId}`,
classId: "rom-npc",
npcIds: [npcId],
maxCasterLevel,
group: "enemy-spell",
unlockLevel: level,
semanticKind: "dot",
sourceSkillIds: ranks.map((rank) => rank.sourceSkillId),
ranks,
});
}
const uniqueComponents = new Map();
for (const ability of [...activeDamageBindings, ...enemyDamageBindings]) {
for (const rank of ability.ranks) {
for (const component of rank.componentIds) uniqueComponents.set(component.componentId, component);
}
}
const output = {
schemaVersion: 1,
source: {
magicCollectDatabase: "Resource/data/magiccollectobject.db",
magicCollectSha256: sha256(magicCollect),
magicObjectDatabase: "Resource/data/magicobject.db",
magicObjectSha256: sha256(magicObject),
magicProcessSource: "SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp",
magicProcessSha256: sha256(magicProcess),
objectStructSource: "SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h",
objectStructSha256: sha256(objectStruct),
periodicRankSource: "SourceCodeVS2019/Libraries/Union/RoleData/RD_Normal.cxx",
periodicRankSourceSha256: sha256(periodicRankSource),
periodicTickSource: "SourceCodeVS2019/Libraries/Union/RoleData/RD_ValueCal.cxx",
periodicTickSourceSha256: sha256(periodicTickSource),
playerCatalog: "src/game/romPlayerCatalog.generated.json",
playerCatalogSha256: sha256(catalogBuffer),
nativeStatRules: "src/game/generated/romNativeStatRules.generated.json",
nativeStatRulesSha256: sha256(nativeStatRulesBuffer),
nativeCombatData: "src/game/generated/romNativeCombatData.generated.json",
nativeCombatDataSha256: sha256(nativeCombatDataBuffer),
npcMagicCollectSha256: nativeCombatData.source?.magicCollectSha256,
npcMagicObjectSha256: nativeCombatData.source?.magicObjectSha256,
nativeNpcGrowthLevelMax: NATIVE_NPC_GROWTH_LEVEL_MAX,
},
scope: {
activePairs: ACTIVE_PAIRS,
semanticKind: "dot",
sourceEffectFilter: "enabled negative-Base DotMagic on HP or HP_Per; resource-drain DotType rows are excluded",
abilityCount: activeDamageBindings.length,
sourceRankCount: activeDamageBindings.reduce((sum, ability) => sum + ability.ranks.length, 0),
uniqueComponentCount: uniqueComponents.size,
enemyNpcCount: enemyDamageBindings.length,
enemyCasterLevelMax: NATIVE_NPC_GROWTH_LEVEL_MAX,
enemySourceRankCount: enemyDamageBindings.reduce((sum, ability) => sum + ability.ranks.length, 0),
enemyUniqueComponentCount: new Set(enemyDamageBindings.flatMap((ability) => ability.ranks.flatMap((rank) => rank.componentIds.map((component) => component.componentId)))).size,
},
recordLayout: {
magicCollect: { offset: DATA_START, recordSize: MAGIC_COLLECT_RECORD_SIZE, type: 21 },
magicObject: { offset: DATA_START, recordSize: MAGIC_OBJECT_RECORD_SIZE, type: 3 },
magicCollectSpellTimeOffset: 0x114,
magicCollectModeOffset: 0x368,
magicCollectModeFixDamageBit: 16,
magicObjectMaxBuffLevelBaseOffset: 0xcc,
magicObjectMaxBuffLevelSkillArgOffset: 0xd0,
magicObjectBuffMaxLevelOffset: 0x3b4,
magicObjectEffectTimeOffset: 0xbc,
magicObjectLevelDeltaEffectTimeOffset: 0xc0,
magicObjectEffectTimeSkillLevelArgOffset: 0xc4,
magicObjectSettingFlagsOffset: 0xd8,
magicObjectDotMagicEnabledBit: COMPONENT_FILTER.dotMagicFlagBit,
magicObjectShowPowerLevelBit: COMPONENT_FILTER.showPowerLevelBit,
magicObjectDotMagicNoKillBit: COMPONENT_FILTER.dotMagicNoKillBit,
magicObjectMaxMagicLevelTargetBoundBit: COMPONENT_FILTER.maxMagicLevelTargetBoundBit,
magicObjectFixDotPowerBit: COMPONENT_FILTER.fixDotPowerBit,
magicObjectDotTimeOffset: 0x184,
magicObjectDotTypeOffset: 0x188,
magicObjectDotBaseOffset: 0x18c,
magicObjectDotSkillLevelArgOffset: 0x190,
magicObjectSettingExFlagsOffset: MAGIC_OBJECT_RECORD_SIZE - 4,
magicObjectIgnoreMagicAbsorbBit: COMPONENT_FILTER.ignoreMagicAbsorbBit,
},
formula: {
sourceOrdering: "MagicProcess computes the target-adjusted buff duration, then adds CalAddDotMagicLv to MagicProc.MagicLv; ShowPowerLv replaces that result with zero before AssistMagic stores Buff.Power. AssistMagic applies MaxBuffLv_Base/Arg, optional target-level and BuffMaxLv caps before storing the integer power.",
sourceTick: "BuffProc emits int(-Dot.Base * (100 + DotSkillLVArg * frozen Buff.Power) / 100) once when remaining Buff.Time is divisible by Dot.Time. HP_Per then multiplies by the target's current MaxHP / 100 and truncates again.",
rankCorrection: "CalAddDotMagicLv returns zero for FixDotPower, non-HP/MP DotType, or DotSkillLVArg zero. For negative HP DotMagic it snapshots Magic/Physics source power and target-specific source defense terms before deriving the stored Buff.Power.",
fixDotMagicalDamageCoefficient: nativeStatRules.playerFix.dotMagicalDamageCoefficient,
formulaFiles: [
"SourceCodeVS2019/Server/NetGameServer/MainProc/MagicProc/MagicProcess.cpp",
"SourceCodeVS2019/Libraries/Union/RoleData/RD_Normal.cxx",
"SourceCodeVS2019/Libraries/Union/RoleData/RD_ValueCal.cxx",
"SourceCodeVS2019/Libraries/Union/RoleData/ObjectStruct.h",
],
},
activeDamageBindings,
enemyDamageBindings,
};
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
console.log(`Wrote ${activeDamageBindings.length} active-five abilities and ${enemyDamageBindings.length} imported NPC bindings with ${output.scope.sourceRankCount + output.scope.enemySourceRankCount} source ranks and ${uniqueComponents.size} unique negative HP DoT components to ${path.relative(projectRoot, outputPath)}.`);
@@ -35,6 +35,18 @@ const MAGIC_COLLECT_RECORD_SIZE = 1032;
const MAGIC_OBJECT_RECORD_SIZE = 992;
const MAGIC_COLLECT_TYPE = 21;
const MAGIC_OBJECT_TYPE = 3;
const PHYSICAL_CALCULATION_TYPES = new Set([0, 3, 9]);
const MAGICAL_CALCULATION_TYPES = new Set([1, 10]);
const SOURCE_CHANNELS = ["earth", "water", "fire", "wind", "light", "darkness"];
const RUNTIME_SCHOOLS_BY_SOURCE_CHANNEL = {
earth: "nature",
water: "frost",
fire: "fire",
wind: "nature",
light: "holy",
darkness: "shadow",
physical: "physical",
};
function sha256(buffer) {
return createHash("sha256").update(buffer).digest("hex");
@@ -128,6 +140,7 @@ function magicComponent(id) {
magicFunc,
magicType,
effectType,
calculationType: int32(magicObject, offset + 0x2d4),
attackType,
damagePower,
fixedValue,
@@ -141,10 +154,27 @@ function magicComponent(id) {
return result;
}
function sourceDamageClassForCalculationType(calculationType) {
if (PHYSICAL_CALCULATION_TYPES.has(calculationType)) return "physical";
if (MAGICAL_CALCULATION_TYPES.has(calculationType)) return "magical";
return undefined;
}
function sourceChannelForMagicType(magicType) {
if (magicType === -1) return "physical";
return SOURCE_CHANNELS[magicType];
}
function schoolFor(component) {
if (component.effectType === 1) return "physical";
return ["nature", "frost", "fire", "nature", "holy", "shadow"][component.magicType]
?? "arcane";
const sourceChannel = sourceChannelForMagicType(component.magicType);
return sourceChannel ? RUNTIME_SCHOOLS_BY_SOURCE_CHANNEL[sourceChannel] : undefined;
}
function attackKindForCalculationType(calculationType) {
if (calculationType === 0 || calculationType === 9) return "melee";
if (calculationType === 3) return "ranged";
if (calculationType === 1 || calculationType === 10) return "spell";
return undefined;
}
function formulaFor(spellId, component) {
@@ -159,7 +189,8 @@ function formulaFor(spellId, component) {
spellId,
componentId: component.id,
spellLevel: 0,
calculationPath: component.effectType === 1 ? "physical" : "magic",
calculationPath: sourceDamageClassForCalculationType(component.calculationType) === "physical"
? "physical" : "magic",
powerKind,
damagePower: component.damagePower,
fixedValue: component.fixedValue,
@@ -257,12 +288,23 @@ for (const [spellId, assignmentMap] of [...assignmentsBySpell].sort((left, right
const effectRange = int32(magicCollect, offset + 0xc0);
const rangeType = int32(magicCollect, offset + 0xc4);
const sourceCooldownSeconds = int32(magicCollect, offset + 0x108);
const primaryDamage = damageComponents[0];
const runtimeDamageComponents = damageComponents.filter((component) => (
sourceDamageClassForCalculationType(component.calculationType)
&& attackKindForCalculationType(component.calculationType)
&& schoolFor(component)
));
const primaryDamage = runtimeDamageComponents[0];
const unsupportedReasons = [];
if (!components.length) unsupportedReasons.push("No referenced MagicObjectDB components were found.");
if (!damageComponents.length) {
unsupportedReasons.push("No component is positively classified as direct HP damage.");
}
if (damageComponents.length !== runtimeDamageComponents.length) {
unsupportedReasons.push(
(damageComponents.length - runtimeDamageComponents.length)
+ " direct-damage component(s) have an unsupported Attack.CalType or MagicType.",
);
}
if (components.length !== damageComponents.length) {
unsupportedReasons.push(
(components.length - damageComponents.length)
@@ -294,7 +336,9 @@ for (const [spellId, assignmentMap] of [...assignmentsBySpell].sort((left, right
delivery: range > 5 ? "projectile" : "melee",
target: "primary",
school: schoolFor(primaryDamage),
animation: range > 5 || primaryDamage.effectType === 0 ? "cast" : "attack",
runewakerEffectType: sourceDamageClassForCalculationType(primaryDamage.calculationType) === "magical" ? 0 : 1,
attackKind: attackKindForCalculationType(primaryDamage.calculationType),
animation: range > 5 || attackKindForCalculationType(primaryDamage.calculationType) === "spell" ? "cast" : "attack",
range,
cooldownMs: Math.max(2500, Math.min(20000, sourceCooldownSeconds > 0
? sourceCooldownSeconds * 1000
@@ -149,6 +149,9 @@ function damageMechanic(entityId, spell) {
delivery: spatial.delivery,
target: spatial.target,
school: runtime.school,
damageOrigin: "runewaker",
attackKind: runtime.attackKind,
runewakerEffectType: runtime.runewakerEffectType,
animation: runtime.animation,
castTimeMs,
range: Math.max(runtime.range, spatial.radius ?? 0),
@@ -5,9 +5,9 @@ head 0 0
hair 0 0
helmet 0 0
shoulder 0 0
torso 0 0
hand 0 0
torso cloth010-001 0 0
hand cloth010-001 0 0
belt 0 0
leg 0 0
foot 0 0
leg cloth010-001 0 0
foot cloth010-001 0 0
back 0 0
1 # HealerMan RuneWaker paperdoll starter appearance
5 hair
6 helmet
7 shoulder
8 torso
9 hand
10 belt
11 leg
12 foot
13 back
@@ -0,0 +1,59 @@
{
"schemaVersion": 1,
"dungeonId": "runewaker-starter-weapons",
"zoneId": 1,
"coordinateSystem": {
"metersPerSourceUnit": 0.1,
"directionUnitsPerTurn": 256,
"maximumProjectionDistance": 2
},
"files": {
"snapshot": "scripts/runewaker-pipeline/snapshots/runewaker-starter-weapons-unused.json",
"generatedSource": "src/game/generated/runewakerStarterWeapons.unused.ts",
"report": "scripts/runewaker-pipeline/snapshots/runewaker-starter-weapons-import-report.json",
"environmentMetadata": "scripts/runewaker-pipeline/snapshots/runewaker-starter-weapons-environment-unused.json",
"navigation": "scripts/runewaker-pipeline/snapshots/runewaker-starter-weapons-navigation-unused.glb",
"actorManifest": "public/assets/equipment/runewaker-v1/import-manifest.json",
"actorOutput": "public/assets/equipment/runewaker-v1"
},
"source": {
"rootEnvironmentVariable": "RUNEWAKER_ROOT",
"relativeDefault": "../Runewaker",
"resourceRoot": "Resource",
"catalogs": []
},
"actors": [
{
"id": "starter-sword",
"sourceModel": "model/item/weapon/blade/act_blade01_000_001.ros",
"runtimeRotationY": 0
},
{
"id": "starter-mace",
"sourceModel": "model/item/weapon/hammer/act_hammer01_000_001.ros",
"runtimeRotationY": 0
},
{
"id": "starter-shield",
"sourceModel": "model/item/weapon/shield/act_shield01_000_001.ros",
"runtimeRotationY": 0
},
{
"id": "starter-staff",
"sourceModel": "model/item/weapon/staff/act_staff00_000_001.ros",
"runtimeRotationY": 0
},
{
"id": "starter-bow",
"sourceModel": "model/item/weapon/bow/act_bow03_010_001.ros",
"runtimeRotationY": 0
},
{
"id": "starter-dagger",
"sourceModel": "model/item/weapon/dagger/act_dagger01_010_005.ros",
"runtimeRotationY": 0
}
],
"templates": [],
"deferred": []
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -559,6 +559,7 @@ function LoadedCharacterModel({
fallbackScale={fallbackEquipmentScale}
models={resolved.equipmentModels}
transforms={equipmentAttachmentTransforms}
onReady={preview ? onReady : undefined}
/>
</>
);
+1 -1
View File
@@ -74,7 +74,7 @@ const selected: CharacterAppearance = {
describe("resolveAvatarAppearance", () => {
it("composes body layers in WoW replacement-texture order", () => {
const resolved = resolveAvatarAppearance(manifest, entry, selected, "priest");
expect(resolved.bodyLayers.map((layer) => layer.url)).toEqual([
expect(resolved.bodyLayers.slice(0, 9).map((layer) => layer.url)).toEqual([
"skin-1",
"pelvis-1",
"torso-1",
+4 -1
View File
@@ -10,6 +10,7 @@ import {
resolveEquipmentAppearance,
type EquipmentModelVisual,
} from "./equipmentAppearance";
import { resolveRunewakerEquipment } from "./runewakerEquipment";
export interface AvatarTextureLayer {
url: string;
@@ -59,7 +60,9 @@ export function resolveAvatarAppearance(
const featureTextures = tripletAt(feature?.byColor ?? [], hairColorIndex);
const regions = manifest.textureLayout.regions;
const layers: AvatarTextureLayer[] = [];
const equipped = resolveEquipmentAppearance(equipment, entry.gender);
const equipped = entry.rendererStrategy?.kind === "runewaker-modular" && classId
? resolveRunewakerEquipment(classId, equipment)
: resolveEquipmentAppearance(equipment, entry.gender, classId);
if (skin?.base) addLayer(layers, skin.base, regions.base);
addLayer(layers, skin?.underwear?.[0] ?? "", regions.pelvisUpper);
+30
View File
@@ -13,6 +13,36 @@ function item(itemId: number): InventoryItem {
}
describe("resolveEquipmentAppearance", () => {
it("gives roster previews and unmapped heirlooms visible clothing without changing inventory", () => {
const heirloom = { itemId: 9_000_001, inventorySlot: "chest", armorType: "cloth" } as InventoryItem;
for (const items of [[], [heirloom]]) {
const resolved = resolveEquipmentAppearance(items, "female", "priest");
expect(resolved.bodyLayers.some((layer) => layer.url.includes("torso-upper-female"))).toBe(true);
expect(resolved.bodyLayers.some((layer) => layer.url.includes("foot.png"))).toBe(true);
expect(resolved.models.map((model) => model.attachment)).toEqual(["shoulderLeft", "shoulderRight"]);
expect(resolved.geosets.Trousers).toBe(2);
}
expect(heirloom.itemId).toBe(9_000_001);
});
it("keeps real equipped appearances ahead of the class armor defaults", () => {
const resolved = resolveEquipmentAppearance([
{ itemId: 6473, inventorySlot: "chest", armorType: "leather" } as InventoryItem,
], "male", "priest");
expect(resolved.bodyLayers.some((layer) => layer.url.includes("6473-armor-of-the-fang"))).toBe(true);
expect(resolved.bodyLayers.some((layer) => layer.url.includes("6465-robe"))).toBe(false);
expect(resolved.geosets.Trousers).toBe(1);
});
it("distinguishes cloth, leather, and heavy starter chest appearances and their caches", () => {
const appearances = ["priest", "rogue", "warrior"] as const;
const resolved = appearances.map((classId) => resolveEquipmentAppearance([], "male", classId));
expect(resolved[0].bodyLayers.some((layer) => layer.url.includes("6465-robe"))).toBe(true);
expect(resolved[1].bodyLayers.some((layer) => layer.url.includes("6473-armor"))).toBe(true);
expect(resolved[2].bodyLayers.some((layer) => layer.url.includes("6627-mutant"))).toBe(true);
expect(new Set(resolved.map((appearance) => appearance.cacheKey)).size).toBe(3);
});
it("maps equipped loot to its exact client-native model attachment", () => {
const resolved = resolveEquipmentAppearance([
item(6472),
+26 -4
View File
@@ -1,4 +1,4 @@
import type { GenderId } from "../app/characterCatalog";
import { classById, type ClassId, type GenderId } from "../app/characterCatalog";
import type { CharacterWeaponStance } from "../game/combatAnimation";
import type { InventoryItem } from "../game/lootTypes";
import type { TextureRegion } from "./characterManifest";
@@ -36,6 +36,8 @@ export interface EquipmentModelVisual {
readonly attachment: EquipmentAttachmentName;
readonly fallbackKind?: "bow" | "dagger" | "mace" | "shield" | "staff" | "sword";
readonly weaponStance?: CharacterWeaponStance;
/** Native RoM props already use the authored RoM socket frame. */
readonly sourceFrame?: "runewaker";
}
interface GenderedTextureLayer {
@@ -318,6 +320,7 @@ const EQUIPMENT_VISUALS: Readonly<Record<number, EquipmentVisualDefinition>> = O
export function resolveEquipmentAppearance(
items: readonly InventoryItem[],
gender: GenderId,
classId?: ClassId,
): ResolvedEquipmentAppearance {
const layers: EquipmentTextureLayer[] = [];
const models: EquipmentModelVisual[] = [];
@@ -325,8 +328,14 @@ export function resolveEquipmentAppearance(
const geosets: Record<string, number> = { Cloak: 0 };
let cloakUrl = "";
const equippedVisuals = items.flatMap((item) => {
const visual = EQUIPMENT_VISUALS[item.itemId];
const starterArmor = classId ? classById(classId).armor : "cloth";
const starterSlots = ["chest", "legs", "feet", "hands", "shoulder", "back"];
const representedSlots = new Set(items.map((item) => item.inventorySlot));
const starterItems = classId ? starterSlots.filter((slot) => !representedSlots.has(slot))
.map((slot) => ({ itemId: 0, inventorySlot: slot, armorType: starterArmor })) : [];
const equippedVisuals = [...starterItems, ...items].flatMap((item) => {
const visual = EQUIPMENT_VISUALS[item.itemId]
?? (classId ? representativeArmorVisual(item.inventorySlot, item.armorType ?? starterArmor) : undefined);
return visual ? [{ item, visual }] : [];
});
@@ -347,7 +356,7 @@ export function resolveEquipmentAppearance(
}
return {
cacheKey: items.map((item) => item.itemId).join(","),
cacheKey: [classId ?? "", ...items.map((item) => `${item.itemId}:${item.inventorySlot}:${item.armorType}`)].join(","),
bodyLayers: layers,
cloakUrl,
geosets,
@@ -355,6 +364,19 @@ export function resolveEquipmentAppearance(
};
}
/** Visible starter/heirloom armor, superseded by exact packaged item visuals. */
function representativeArmorVisual(slot: string, armor: string): EquipmentVisualDefinition | undefined {
switch (slot) {
case "chest": return EQUIPMENT_VISUALS[armor === "cloth" ? 6465 : armor === "leather" ? 6473 : 6627];
case "legs": return EQUIPMENT_VISUALS[10410];
case "feet": return EQUIPMENT_VISUALS[10411];
case "hands": return EQUIPMENT_VISUALS[5970];
case "shoulder": return EQUIPMENT_VISUALS[armor === "cloth" ? 6461 : 5404];
case "back": return EQUIPMENT_VISUALS[6632];
default: return undefined;
}
}
export function equipmentModelUrls(): readonly string[] {
return Object.values(EQUIPMENT_VISUALS)
.flatMap((visual) => visual.models?.map((itemModel) => itemModel.url) ?? []);
+15 -2
View File
@@ -115,16 +115,24 @@ function AttachedEquipmentModel({
entry,
itemModel,
transform,
onReady,
}: {
readonly entry: AvatarManifestEntry;
readonly itemModel: EquipmentModelVisual;
readonly transform: AvatarAttachmentTransform;
readonly onReady?: () => void;
}): ReactNode {
const gltf = useGLTF(resolveContentUrl(itemModel.url));
const scene = useMemo(
() => cloneEquipmentScene(gltf.scene, `${itemModel.itemId}-${itemModel.attachment}`),
[gltf.scene, itemModel.attachment, itemModel.itemId],
);
useEffect(() => {
if (!onReady) return;
// Include the ready pose after its initial blend, not the rig's bind-pose bounds.
const timer = window.setTimeout(onReady, 500);
return () => window.clearTimeout(timer);
}, [onReady, scene]);
useEffect(() => () => {
const materials = new Set<Material>();
scene.traverse((node) => {
@@ -136,7 +144,7 @@ function AttachedEquipmentModel({
materials.forEach((material) => material.dispose());
}, [scene]);
const socketRotation = itemModel.fallbackKind && itemModel.weaponStance
const socketRotation = itemModel.sourceFrame !== "runewaker" && itemModel.fallbackKind && itemModel.weaponStance
? avatarWeaponSocketRotation(entry, itemModel.fallbackKind, itemModel.weaponStance)
: NO_ROTATION;
@@ -192,11 +200,13 @@ function ResolvedEquipmentAttachment({
fallbackScale,
itemModel,
transform,
onReady,
}: {
readonly entry: AvatarManifestEntry;
readonly fallbackScale: number;
readonly itemModel: EquipmentModelVisual;
readonly transform: AvatarAttachmentTransform;
readonly onReady?: () => void;
}): ReactNode {
const fallback = (
<EquipmentModelFallback
@@ -212,7 +222,7 @@ function ResolvedEquipmentAttachment({
resetKey={`${itemModel.itemId}:${itemModel.attachment}:${itemModel.url}`}
>
<Suspense fallback={fallback}>
<AttachedEquipmentModel entry={entry} itemModel={itemModel} transform={transform} />
<AttachedEquipmentModel entry={entry} itemModel={itemModel} transform={transform} onReady={onReady} />
</Suspense>
</AvatarErrorBoundary>
);
@@ -223,11 +233,13 @@ export function EquipmentAttachments({
fallbackScale,
models,
transforms,
onReady,
}: {
readonly entry: AvatarManifestEntry;
readonly fallbackScale: number;
readonly models: readonly EquipmentModelVisual[];
readonly transforms: EquipmentAttachmentTransforms;
readonly onReady?: () => void;
}): ReactNode {
return models.map((itemModel) => {
const transform = transforms[itemModel.attachment];
@@ -238,6 +250,7 @@ export function EquipmentAttachments({
fallbackScale={fallbackScale}
itemModel={itemModel}
transform={transform}
onReady={onReady}
/>
) : null;
});
+42
View File
@@ -0,0 +1,42 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { CLASSES } from "../app/characterCatalog";
import type { InventoryItem } from "../game/lootTypes";
import { resolveRunewakerEquipment } from "./runewakerEquipment";
describe("original RoM equipment", () => {
it("equips every RoM class with source-client props in its native socket frame", () => {
for (const characterClass of CLASSES.filter((entry) => entry.id.startsWith("rom-"))) {
const resolved = resolveRunewakerEquipment(characterClass.id, []);
expect(resolved.models.length).toBeGreaterThan(0);
expect(resolved.bodyLayers).toEqual([]);
for (const model of resolved.models) {
expect(model.sourceFrame).toBe("runewaker");
expect(model.url).toContain("/equipment/runewaker-v1/");
expect(existsSync(resolve("public", model.url.slice(1)))).toBe(true);
}
}
});
it("shows the equipped weapon type and avoids applying WoW armor to native RoM UVs", () => {
const resolved = resolveRunewakerEquipment("rom-priest", [
{ itemId: 123, inventorySlot: "main-hand", weaponType: "mace" } as InventoryItem,
{ itemId: 6465, inventorySlot: "chest", armorType: "cloth" } as InventoryItem,
]);
expect(resolved.models).toEqual([expect.objectContaining({
itemId: 123, url: expect.stringContaining("starter-mace.glb"), attachment: "handRight",
})]);
expect(resolved.bodyLayers).toEqual([]);
});
it("preserves original ROS provenance for all six packaged weapon kinds", () => {
const imported = JSON.parse(readFileSync("public/assets/equipment/runewaker-v1/import-manifest.json", "utf8"));
expect(imported.assets).toHaveLength(6);
expect(imported.proceduralFallbacks).toEqual([]);
for (const asset of imported.assets) {
expect(asset.sourceModel).toMatch(/^model\/item\/weapon\/.+\.ros$/);
expect(asset.sourceModelSha256).toMatch(/^[a-f0-9]{64}$/);
}
});
});
+46
View File
@@ -0,0 +1,46 @@
import type { ClassId } from "../app/characterCatalog";
import type { InventoryItem } from "../game/lootTypes";
import type { EquipmentModelVisual, ResolvedEquipmentAppearance } from "./equipmentAppearance";
import { starterWeaponLoadout, type StarterWeaponKind } from "./starterWeapons";
export const RUNEWAKER_STARTER_WEAPON_ROOT = "/assets/equipment/runewaker-v1";
/** Original client props; the character GLB contains the native worn paperdoll. */
export function resolveRunewakerEquipment(
classId: ClassId,
equipment: readonly InventoryItem[],
): ResolvedEquipmentAppearance {
const loadout = starterWeaponLoadout(classId);
const models: EquipmentModelVisual[] = loadout.items.map((starter, index) => {
const equipped = equipment.find((item) => starter.kind === "shield"
? item.armorType === "shield"
: starter.kind === "bow"
? item.weaponType === "bow" || item.weaponType === "crossbow"
: starter.hand === "left"
? item.inventorySlot === "off-hand" && Boolean(item.weaponType)
: ["main-hand", "one-hand", "two-hand"].includes(item.inventorySlot));
const weaponType = equipped?.weaponType;
const kind: StarterWeaponKind = starter.kind === "shield" ? "shield"
: weaponType === "staff" ? "staff"
: weaponType === "dagger" ? "dagger"
: weaponType === "mace" || weaponType === "hammer" ? "mace"
: weaponType === "sword" || weaponType === "axe" ? "sword"
: starter.kind;
return {
itemId: equipped?.itemId ?? -(index + 1),
name: `RoM starter ${kind}`,
url: `${RUNEWAKER_STARTER_WEAPON_ROOT}/starter-${kind}.glb`,
attachment: kind === "shield" ? "shield" : starter.hand === "left" ? "handLeft" : "handRight",
fallbackKind: kind,
weaponStance: kind === "staff" ? "two-handed-left" : kind === "bow" ? "bow" : loadout.stance,
sourceFrame: "runewaker",
};
});
return {
cacheKey: `${classId}:${models.map((model) => `${model.itemId}:${model.url}`).join(",")}`,
bodyLayers: [],
cloakUrl: "",
geosets: {},
models,
};
}
+6
View File
@@ -121,6 +121,12 @@ describe("browser content packs", () => {
totalBytes: firstBytes.byteLength,
error: "",
});
const entriesSpy = vi.spyOn(Object, "entries");
for (let index = 0; index < 100; index += 1) {
expect(contentPackIsUsable("characters:test")).toBe(true);
}
expect(entriesSpy).not.toHaveBeenCalled();
entriesSpy.mockRestore();
const active = storage.stores.get(BROWSER_CONTENT_CACHE_NAME);
expect(await (await active?.match(browserContentObjectPath(manifest.files["/assets/characters/test.glb"])))?.text()).toBe("first-model");
const originalObject = browserContentObjectPath(manifest.files["/assets/characters/test.glb"]);
+9 -1
View File
@@ -78,6 +78,7 @@ let initialization: Promise<void> | null = null;
let installedStorageValidation: Promise<void> | null = null;
let installedStorageValidationComplete = Object.keys(installedState.packs).length === 0;
const validatedInstalledFiles = new Set<string>();
const usablePackCache = new Map<string, boolean>();
function safeLocalStorage(): Storage | null {
try { return typeof window === "undefined" ? null : window.localStorage; } catch { return null; }
@@ -148,6 +149,9 @@ function persistInstalledState(): void {
}
function publish(patch: Partial<Omit<ContentManagerSnapshot, "revision">>): void {
// Every receipt/storage/manifest change publishes before notifying consumers.
// Cache both usable and unavailable packs until the next content revision.
usablePackCache.clear();
snapshot = { ...snapshot, ...patch, revision: snapshot.revision + 1 };
listeners.forEach((listener) => listener());
}
@@ -364,7 +368,11 @@ function contentPackIsUsableInternal(packId: string, visiting: ReadonlySet<strin
}
export function contentPackIsUsable(packId: string): boolean {
return contentPackIsUsableInternal(packId, new Set());
const cached = usablePackCache.get(packId);
if (cached !== undefined) return cached;
const usable = contentPackIsUsableInternal(packId, new Set());
usablePackCache.set(packId, usable);
return usable;
}
/** Vite serves the complete source asset tree in development; shipped builds require an installed pack. */
+9
View File
@@ -48,6 +48,15 @@ describe("content manifest", () => {
}).map(([logicalPath]) => logicalPath)).toEqual(["/assets/b.glb"]);
});
it("reuses the pack index and replaces it when a new catalog file map arrives", () => {
const first = filesForContentPack(manifest, "dungeon:test");
expect(filesForContentPack(manifest, "dungeon:test")).toBe(first);
const updated = { ...manifest, files: { "/assets/b.glb": manifest.files["/assets/b.glb"] } };
expect(filesForContentPack(updated, "dungeon:test").map(([url]) => url)).toEqual(["/assets/b.glb"]);
expect(filesForContentPack(manifest, "dungeon:test")).toBe(first);
expect(filesForContentPack(updated, "missing")).toEqual([]);
});
it("resolves a seeded run's logical models to distinct owning packs", () => {
const withSecondPack: ContentManifest = {
...manifest,
+21 -3
View File
@@ -118,13 +118,31 @@ export function appVersionMeetsMinimum(current: string, minimum: string): boolea
return true;
}
type ContentPackFiles = readonly (readonly [string, ContentManifestFile])[];
const packFileIndexes = new WeakMap<ContentManifest["files"], ReadonlyMap<string, ContentPackFiles>>();
const EMPTY_PACK_FILES: ContentPackFiles = [];
/** Catalog file maps are immutable; index once instead of scanning during gameplay. */
export function filesForContentPack(
manifest: ContentManifest,
packId: string,
): readonly (readonly [string, ContentManifestFile])[] {
return Object.entries(manifest.files)
.filter(([, entry]) => entry.packId === packId)
.sort(([left], [right]) => left.localeCompare(right));
let index = packFileIndexes.get(manifest.files);
if (!index) {
const groups = new Map<string, [string, ContentManifestFile][]>();
for (const [logicalPath, entry] of Object.entries(manifest.files)) {
let files = groups.get(entry.packId);
if (!files) {
files = [];
groups.set(entry.packId, files);
}
files.push([logicalPath, entry]);
}
for (const files of groups.values()) files.sort(([left], [right]) => left.localeCompare(right));
index = groups;
packFileIndexes.set(manifest.files, index);
}
return index.get(packId) ?? EMPTY_PACK_FILES;
}
export interface ContentFileIdentity {
+8
View File
@@ -127,6 +127,12 @@ export interface AbilityRange {
}
export interface AbilityAmountScaling {
/** Raw RuneWaker component identity; native formulas never infer this from a WoW coefficient. */
readonly romNativeSource?: {
readonly skillId: number;
readonly componentId: number;
readonly kind: "damage" | "heal" | "dot" | "hot";
};
/**
* The client DBC's deterministic amount before its random die roll. Imported
* spells use this instead of treating the spell-power coefficient as the
@@ -353,6 +359,8 @@ export interface AbilityDefinition {
readonly ai?: AbilityAiHints;
readonly passive?: boolean;
readonly source?: "core" | "wow335-progression" | "ascension-progression" | "coa-starter" | "coa-progression" | "coa-tree" | "runewaker";
/** Source combat delivery metadata independent of a native skill's damage school. */
readonly sourceAttackKind?: "melee" | "ranged" | "spell";
readonly romSkillGroup?: RomSkillGroup;
/** The original client level before Healer Man's starter-kit remapping. */
readonly sourceUnlockLevel?: number;
+45 -6
View File
@@ -15,7 +15,13 @@ import type { PartyState } from "../partyStore";
import { usePartyStore } from "../partyStore";
import type { PartyRole } from "../partyRoles";
import type { BenchmarkScenarioDefinition } from "./benchmarkTypes";
import { COMBAT_ROTATION_VERSION, evaluateCombatRotation } from "../combatRotation";
import { nativeRotationAbilityAllowed, nativeRotationProfileFor } from "../romFiveRotation";
import {
COMBAT_ROTATION_VERSION,
evaluateCombatRotation,
nativeEarlyPriestHealReserveBlocksAbility,
type RotationContext,
} from "../combatRotation";
export const BENCHMARK_POLICY_ID = `rotation-v4:${COMBAT_ROTATION_VERSION}`;
@@ -193,19 +199,37 @@ export class BenchmarkController {
) return { made: false, cast: false, resourceBlocked: false, invalidFailures: 0 };
const targets = friendlyTargets(combat, party);
const availableAbilities = combat.abilities
const learnedAbilities = combat.abilities
.map((ability) => learnedAbility(ability, combat))
.filter((ability): ability is AbilityDefinition => ability !== null)
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability));
.filter((ability): ability is AbilityDefinition => ability !== null);
const availableAbilities = learnedAbilities
.filter((ability) => !moving || ability.castTimeMs <= 0 || canAbilityCastWhileMoving(ability))
.filter((ability) => combat.statRuleset !== "rom" || (
(combat.cooldowns[ability.id] ?? 0) <= now
&& (ability.target !== "hostile" || (() => {
const position = combat.selectedTargetId ? getMobPosition(combat.selectedTargetId) : null;
if (!position) return false;
const distance = Math.hypot(...position.map((value, index) => value - combat.playerPosition[index]));
return distance >= ability.range.min && distance <= ability.range.max;
})())
));
const candidates = availableAbilities
.map((ability) => scoreAbility(ability, role, scenario, combat, targets))
.filter((candidate): candidate is ScoredAbility => candidate !== null)
.sort((left, right) => right.score - left.score || left.ability.id.localeCompare(right.ability.id));
const hostileTarget = combat.selectedTargetId ? combat.mobs[combat.selectedTargetId] : null;
const sharedDecision = evaluateCombatRotation({
const rotationContext: RotationContext = {
now,
canUseAbility: combat.statRuleset === "rom" ? (ability) =>
resolvedAbilityCosts(ability, combat.talentModifiers, combat).every((cost) =>
cost.amount <= (cost.resource === "health" ? combat.health : combat.resourcePools[cost.resource] ?? 0))
: undefined,
manaCostForAbility: (ability) => resolvedAbilityCosts(ability, combat.talentModifiers, combat)
.filter((cost) => cost.resource === "mana")
.reduce((total, cost) => total + cost.amount, 0),
classId: combat.classId,
secondaryClassId: combat.secondaryClassId,
statRuleset: combat.statRuleset,
specializationId: combat.specializationId,
role,
level: combat.level,
@@ -219,6 +243,7 @@ export class BenchmarkController {
comboTargetId: combat.comboTargetId,
runes: combat.deathKnightRunes,
abilities: availableAbilities,
learnedAbilities,
knownDamagingAbilityNames: new Set(availableAbilities.filter((ability) => {
const categories = aiHintsForAbility(ability).categories;
return categories.includes("damage") || categories.includes("execute");
@@ -248,6 +273,12 @@ export class BenchmarkController {
&& effect.aura.id === aura.definition.id))
.map((ability) => ability.id)),
]),
maintainedPeriodicEffects: combat.effects.map((effect) => ({
abilityId: effect.sourceAbilityId,
sourceActorId: effect.sourceActorId,
targetId: effect.targetId ?? PLAYER_AGGRO_ID,
remainingMs: Math.max(0, effect.nextTickAt + (effect.remainingTicks - 1) * effect.intervalMs - now),
})),
activeAuraExclusiveGroups: new Set(combat.auras
.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID && (aura.expiresAt === null || aura.expiresAt > now))
.flatMap((aura) => aura.definition.exclusiveGroup ? [aura.definition.exclusiveGroup] : [])),
@@ -260,7 +291,15 @@ export class BenchmarkController {
return source?.effects.some((effect) => effect.kind === "pet"
&& ["summon", "call", "resurrect", "tame"].includes(effect.action)) ?? false;
}),
});
};
const sharedDecision = evaluateCombatRotation(rotationContext);
for (let index = candidates.length - 1; index >= 0; index -= 1) {
const ability = candidates[index].ability;
if (nativeEarlyPriestHealReserveBlocksAbility(ability, rotationContext)
|| (nativeRotationProfileFor(rotationContext) && !nativeRotationAbilityAllowed(ability, rotationContext))) {
candidates.splice(index, 1);
}
}
if (sharedDecision) {
const friendlyTarget = sharedDecision.targetId
? targets.find((target) => target.id === sharedDecision.targetId) ?? null
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import {
COA_FIRST_PARTY_MEMBERS,
coaFirstPartyBuildCatalog,
coaFirstPartySimulationConfig,
createCoaFirstPartyCompanions,
loadCoaFirstPartyBuilds,
} from "./coaFirstParty";
import { READY_COMPANION_SPECIALIZATION_IDS, readinessStatusFor } from "../companionSpecReadiness";
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
describe("CoA first-party diagnostic composition", () => {
it("defines the selected five as one tank, one healer, and three DPS seats", () => {
expect(COA_FIRST_PARTY_MEMBERS.map(({ classId, specializationId, specialization, role }) => (
[classId, specializationId, specialization, role]
))).toEqual([
["guardian", "guardian:vanguard", "Vanguard", "tank"],
["witch-doctor", "witch-doctor:brewing", "Brewing", "healer"],
["barbarian", "barbarian:brutality", "Brutality", "melee"],
["ranger", "ranger:archery", "Archery", "ranged"],
["pyromancer", "pyromancer:incineration", "Incineration", "caster"],
]);
expect(COA_FIRST_PARTY_MEMBERS.filter((member) => member.partyRole === "tank")).toHaveLength(1);
expect(COA_FIRST_PARTY_MEMBERS.filter((member) => member.partyRole === "healer")).toHaveLength(1);
expect(COA_FIRST_PARTY_MEMBERS.filter((member) => member.partyRole === "damage")).toHaveLength(3);
});
it("resolves only the five explicit build identities and preserves the Ready baseline", () => {
const catalog = coaFirstPartyBuildCatalog(60);
expect(catalog.map((build) => [build.classId, build.specializationId, build.orientation, build.role, build.source]))
.toEqual(COA_FIRST_PARTY_MEMBERS.map((member) => [
member.classId,
member.specializationId,
member.specialization,
member.partyRole,
"coa",
]));
expect(new Set(catalog.map((build) => build.id)).size).toBe(5);
expect(READY_COMPANION_SPECIALIZATION_IDS).toEqual([
"warrior:protection",
"priest:discipline",
"hunter:marksmanship",
"mage:fire",
"rogue:assassination",
]);
expect(COA_FIRST_PARTY_MEMBERS.map((member) => readinessStatusFor(member.specializationId)))
.toEqual(["needs-testing", "needs-testing", "needs-testing", "needs-testing", "needs-testing"]);
});
it("loads legal talent allocations for the exact five", async () => {
const builds = await loadCoaFirstPartyBuilds(20);
expect(builds.map((build) => [build.classId, build.orientation, build.role, build.source]))
.toEqual(COA_FIRST_PARTY_MEMBERS.map((member) => [
member.classId,
member.specialization,
member.partyRole,
"coa",
]));
for (const build of builds) {
expect(runtimeNormalizeTalentRanks(build.classId, build.level, build.talentRanks))
.toEqual(build.talentRanks);
}
}, 60_000);
it("keeps the same four configured companion builds for every active player seat", async () => {
const builds = await loadCoaFirstPartyBuilds(20);
for (let activeSlot = 0; activeSlot < 5; activeSlot += 1) {
const companions = createCoaFirstPartyCompanions(builds, activeSlot, "coa-first-party-test");
expect(companions).toHaveLength(4);
for (const [slot, build] of builds.entries()) {
if (slot === activeSlot) continue;
const companion = companions.find((candidate) => candidate.id.includes(`-${slot}-`));
expect(companion).toBeDefined();
expect(companion).toMatchObject({
classId: build.classId,
specialization: build.orientation,
specializationId: COA_FIRST_PARTY_MEMBERS[slot].specializationId,
role: build.role,
talentRanks: build.talentRanks,
});
}
}
}, 60_000);
it("rejects supplied builds with non-normalized talent rank entries", async () => {
const builds = await loadCoaFirstPartyBuilds(20);
for (const rank of [-1, 0, Number.NaN, Number.POSITIVE_INFINITY, 0.5]) {
const invalid = builds.map((build, slot) => slot === 0 ? {
...build,
talentRanks: { ...build.talentRanks, "invalid-node": rank },
} : build);
expect(() => createCoaFirstPartyCompanions(invalid, 1, "invalid-talents"))
.toThrow(/illegal or non-normalized talent ranks/);
}
}, 60_000);
it("builds a deterministic Balance Lab request with explicit bosses and a zero-based seat", () => {
const config = coaFirstPartySimulationConfig(60, ["deadmines-van-cleef", "shadowfang-archmage-arugal"]);
const catalog = coaFirstPartyBuildCatalog(60);
expect(config).toMatchObject({
schemaVersion: 1,
source: "coa",
level: 60,
durationSeconds: 30,
seeds: [1],
partyBuildIds: catalog.map((build) => build.id),
activeSlot: 1,
bossCategory: "wow",
bossKeys: ["deadmines-van-cleef", "shadowfang-archmage-arugal"],
});
expect(coaFirstPartySimulationConfig(60, ["boss"], 4).activeSlot).toBe(4);
});
it("rejects missing bosses, invalid levels, duplicate bosses, and invalid active seats", () => {
expect(() => coaFirstPartySimulationConfig(60, [])).toThrow(/explicit boss key/);
expect(() => coaFirstPartySimulationConfig(0, ["boss"])).toThrow(/level must be an integer/);
expect(() => coaFirstPartySimulationConfig(81, ["boss"])).toThrow(/level must be an integer/);
expect(() => coaFirstPartySimulationConfig(60, ["boss", "boss"])).toThrow(/must be unique/);
expect(() => coaFirstPartySimulationConfig(60, ["boss"], -1)).toThrow(/activeSlot/);
expect(() => coaFirstPartySimulationConfig(60, ["boss"], 5)).toThrow(/activeSlot/);
});
});
+198
View File
@@ -0,0 +1,198 @@
import { classById, type CoaClassId } from "../../app/characterCatalog";
import {
COA_FIRST_PARTY_CLASS_IDS,
COA_FIRST_PARTY_MEMBERS,
type CoaFirstPartyMemberDefinition,
} from "../coaFirstPartyRoster";
import { createConfiguredPartyMember, type PartyMember } from "../partyStore";
import { runtimeNormalizeTalentRanks } from "../talentRuntimeCatalog";
import {
loadSelectableBenchmarkBuilds,
selectableBenchmarkBuildCatalog,
type SelectableBenchmarkBuildCatalogEntry,
} from "./benchmarkBuilds";
import type { BenchmarkBuildDefinition } from "./benchmarkTypes";
export { COA_FIRST_PARTY_MEMBERS } from "../coaFirstPartyRoster";
function checkedLevel(level: number): number {
if (!Number.isInteger(level) || level < 1 || level > 80) {
throw new RangeError("CoA first-party level must be an integer from 1 through 80.");
}
return level;
}
function checkedActiveSlot(activeSlot: number): number {
if (!Number.isInteger(activeSlot) || activeSlot < 0 || activeSlot >= COA_FIRST_PARTY_MEMBERS.length) {
throw new RangeError("CoA first-party activeSlot must be an integer from 0 through 4.");
}
return activeSlot;
}
function sameTalentRanks(
left: Readonly<Record<string, number>>,
right: Readonly<Record<string, number>>,
): boolean {
const normalize = (ranks: Readonly<Record<string, number>>) => Object.entries(ranks)
.sort(([leftId], [rightId]) => leftId.localeCompare(rightId));
return JSON.stringify(normalize(left)) === JSON.stringify(normalize(right));
}
function expectedMemberForBuild(
build: Pick<BenchmarkBuildDefinition, "classId" | "orientation" | "role" | "source" | "level" | "id" | "talentRanks">,
index: number,
): CoaFirstPartyMemberDefinition {
const expected = COA_FIRST_PARTY_MEMBERS[index];
if (!expected) throw new Error(`Unexpected CoA first-party slot ${index + 1}.`);
if (build.source !== "coa"
|| build.classId !== expected.classId
|| build.orientation !== expected.specialization
|| build.role !== expected.partyRole) {
throw new Error(
`CoA first-party slot ${index + 1} must be ${expected.classId}:${expected.specializationId} (${expected.partyRole}).`,
);
}
if (!build.id) throw new Error(`CoA first-party slot ${index + 1} has no build ID.`);
return expected;
}
function validateBuilds(
builds: readonly BenchmarkBuildDefinition[],
): number {
if (builds.length !== COA_FIRST_PARTY_MEMBERS.length) {
throw new Error("A CoA first-party diagnostic requires exactly five builds in tank/healer/melee/ranged/caster order.");
}
const ids = new Set<string>();
let level: number | null = null;
let canonicalIds: ReadonlyMap<string, string> | null = null;
for (const [index, build] of builds.entries()) {
expectedMemberForBuild(build, index);
if (ids.has(build.id)) throw new Error(`Duplicate CoA first-party build ID: ${build.id}.`);
ids.add(build.id);
if (level === null) level = checkedLevel(build.level);
else if (build.level !== level) throw new Error("CoA first-party builds must use one shared level.");
}
canonicalIds = new Map(coaFirstPartyBuildCatalog(level!).map((entry) => [entry.specializationId ?? "", entry.id]));
for (const build of builds) {
const specialization = COA_FIRST_PARTY_MEMBERS.find((member) => (
member.classId === build.classId && member.specialization === build.orientation
));
if (!specialization || canonicalIds.get(specialization.specializationId) !== build.id) {
throw new Error(`CoA first-party build ${build.id} does not match the canonical selector for its class and specialization.`);
}
const normalized = selectableTalentRanks(specialization.classId, build.level, build.talentRanks);
if (!sameTalentRanks(build.talentRanks, normalized)) {
throw new Error(`CoA first-party build ${build.id} has illegal or non-normalized talent ranks.`);
}
}
return level!;
}
function selectableTalentRanks(
classId: CoaClassId,
level: number,
ranks: Readonly<Record<string, number>>,
): Readonly<Record<string, number>> {
// Keep the legality check in the same runtime allocator used by the build loader.
return runtimeNormalizeTalentRanks(classId, level, ranks);
}
function exactCatalogMember(
catalog: readonly SelectableBenchmarkBuildCatalogEntry[],
expected: CoaFirstPartyMemberDefinition,
): SelectableBenchmarkBuildCatalogEntry {
const matches = catalog.filter((build) => (
build.source === "coa"
&& build.classId === expected.classId
&& build.specializationId === expected.specializationId
&& build.orientation === expected.specialization
&& build.role === expected.partyRole
));
if (matches.length !== 1) {
throw new Error(
`Expected exactly one selectable CoA build for ${expected.classId}:${expected.specializationId}; found ${matches.length}.`,
);
}
return matches[0];
}
/** Resolves the five exact selectors without loading catalogs or substituting builds. */
export function coaFirstPartyBuildCatalog(level = 60): readonly SelectableBenchmarkBuildCatalogEntry[] {
const safeLevel = checkedLevel(level);
const catalog = selectableBenchmarkBuildCatalog(safeLevel, COA_FIRST_PARTY_CLASS_IDS);
return Object.freeze(COA_FIRST_PARTY_MEMBERS.map((member) => exactCatalogMember(catalog, member)));
}
/** Loads legal talent allocations for the five exact classes and specifications. */
export async function loadCoaFirstPartyBuilds(level = 60): Promise<readonly BenchmarkBuildDefinition[]> {
const safeLevel = checkedLevel(level);
const catalog = await loadSelectableBenchmarkBuilds(safeLevel, COA_FIRST_PARTY_CLASS_IDS);
const builds = COA_FIRST_PARTY_MEMBERS.map((member) => {
const matches = catalog.filter((build) => (
build.source === "coa"
&& build.classId === member.classId
&& build.orientation === member.specialization
&& build.role === member.partyRole
));
if (matches.length !== 1) {
throw new Error(
`Expected exactly one loaded CoA build for ${member.classId}:${member.specializationId}; found ${matches.length}.`,
);
}
return matches[0];
});
validateBuilds(builds);
return Object.freeze(builds);
}
/** Creates the four exact companion actors around whichever party seat is active. */
export function createCoaFirstPartyCompanions(
builds: readonly BenchmarkBuildDefinition[],
activeSlot: number,
seed: string,
): readonly PartyMember[] {
const active = checkedActiveSlot(activeSlot);
validateBuilds(builds);
return Object.freeze(builds.flatMap((build, slot) => {
if (slot === active) return [];
const expected = expectedMemberForBuild(build, slot);
return [createConfiguredPartyMember({
name: build.characterName ?? `${classById(expected.classId).name} ${expected.specialization}`,
classId: expected.classId,
raceId: build.raceId,
secondaryClassId: build.secondaryClassId,
specialization: expected.specialization,
specializationId: expected.specializationId,
talentTreeId: expected.specializationId,
talentRanks: build.talentRanks,
role: expected.partyRole,
}, build.level, slot, seed)];
}));
}
/** Produces the existing Balance Lab request shape with a mandatory boss allowlist. */
export function coaFirstPartySimulationConfig(
level: number,
bossKeys: readonly string[],
activeSlot = 1,
) {
const safeLevel = checkedLevel(level);
const safeActiveSlot = checkedActiveSlot(activeSlot);
if (!bossKeys.length || bossKeys.some((key) => typeof key !== "string" || key.trim().length === 0)) {
throw new Error("CoA first-party simulation requires at least one non-empty explicit boss key.");
}
if (new Set(bossKeys).size !== bossKeys.length) {
throw new Error("CoA first-party simulation boss keys must be unique.");
}
return Object.freeze({
schemaVersion: 1 as const,
source: "coa" as const,
level: safeLevel,
durationSeconds: 30,
seeds: Object.freeze([1]),
partyBuildIds: Object.freeze(coaFirstPartyBuildCatalog(safeLevel).map((build) => build.id)),
activeSlot: safeActiveSlot,
bossCategory: "wow" as const,
bossKeys: Object.freeze([...bossKeys]),
});
}
+1
View File
@@ -113,6 +113,7 @@ function describeEffect(effect: EnemyAbilityEffect | undefined, attack: EnemyAtt
return `${titleCase(attack.school)} damage to ${target} (${Number(multiplier.toFixed(2))}× boss power)`;
}
if (effect.kind === "heal") return `Heals ${target} for ${effect.multiplier}× boss power`;
if (effect.kind === "dot") return `${titleCase(attack.school)} damage to ${target} every ${seconds(effect.intervalMs)} for ${effect.ticks} ticks; dispellable`;
if (effect.kind === "leech") {
return `Drains ${target} for ${effect.damageMultiplier}× boss power and heals the caster for ${effect.healMultiplier}× boss power`;
}
+14 -7
View File
@@ -21,6 +21,9 @@ const UNSOURCED_BASE_RANK_SPELL_IDS = new Set([
502889,
560469,
]);
// These newly exposed Talent actions retain source-operation markers until
// their bespoke copy/specific cooldown-reset behavior is implemented.
const UNMODELED_TALENT_ACTION_SPELL_IDS = new Set([681007, 704420]);
const abilities = Object.values(COA_ABILITIES_BY_CLASS).flat();
it("keeps Drakkari Barrier friendly and its retaliation in the damage-taken proc", () => {
@@ -79,9 +82,9 @@ describe("Ascension Conquest ability catalog", () => {
it("covers every class, source family, and playable rank", () => {
expect(Object.keys(COA_ABILITIES_BY_CLASS).sort()).toEqual([...COA_CLASS_IDS].sort());
expect(Object.keys(COA_CLASS_RESOURCES).sort()).toEqual([...COA_CLASS_IDS].sort());
expect(abilities).toHaveLength(1_276);
expect(ranks).toHaveLength(3_395);
expect(new Set(ranks.map(({ rank }) => rank.spellId)).size).toBe(3_348);
expect(abilities).toHaveLength(1_400);
expect(ranks).toHaveLength(3_519);
expect(new Set(ranks.map(({ rank }) => rank.spellId)).size).toBe(3_464);
const runtimeSpellIds = new Set(ranks.map(({ rank }) => rank.spellId));
for (const chain of COA_PROGRESSION_CHAINS) {
@@ -100,7 +103,7 @@ describe("Ascension Conquest ability catalog", () => {
}
});
it("gives every rank finite data and every sourced rank an executable effect", () => {
it("gives every rank finite data and preserves explicit unsourced or unmodeled operations", () => {
expect(new Set(abilities.map((ability) => ability.id)).size).toBe(abilities.length);
for (const ability of abilities) {
expect(ability.classId, ability.id).toBeTruthy();
@@ -122,9 +125,13 @@ describe("Ascension Conquest ability catalog", () => {
expect(rank.cost.amount, label).toBeGreaterThanOrEqual(0);
expect(rank.effects.length, label).toBeGreaterThan(0);
expect(rank.effects.some((effect) => effect.kind === "scripted" || effect.kind === "unsupported"), label).toBe(false);
if (!ability.passive && !UNSOURCED_BASE_RANK_SPELL_IDS.has(rank.spellId)) {
if (!ability.passive && !UNSOURCED_BASE_RANK_SPELL_IDS.has(rank.spellId)
&& !UNMODELED_TALENT_ACTION_SPELL_IDS.has(rank.spellId)) {
expect(rank.effects.some((effect) => effect.kind !== "utility" || effect.action !== "class-script"), label).toBe(true);
}
if (UNMODELED_TALENT_ACTION_SPELL_IDS.has(rank.spellId)) {
expect(rank.effects.every((effect) => effect.kind === "utility" && effect.action === "class-script"), label).toBe(true);
}
}
}
});
@@ -235,7 +242,7 @@ describe("Ascension Conquest ability catalog", () => {
it("ships executable hidden payloads for proc-triggered DBC spell IDs", () => {
// Geode Barrage now uses its ranked parent payload instead of hidden placeholder 803138.
expect(Object.keys(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)).toHaveLength(578);
expect(Object.keys(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)).toHaveLength(670);
expect(COA_TRIGGERED_ABILITIES_BY_SPELL_ID[803138]).toBeUndefined();
expect(COA_TRIGGERED_ABILITIES_BY_SPELL_ID[800951]).toMatchObject({
name: "Battle Rhythm",
@@ -243,7 +250,7 @@ describe("Ascension Conquest ability catalog", () => {
});
expect(Object.values(COA_TRIGGERED_ABILITIES_BY_SPELL_ID).filter((ability) => (
ability.effects.some((effect) => effect.kind !== "utility")
))).toHaveLength(536);
))).toHaveLength(621);
for (const [spellId, ability] of Object.entries(COA_TRIGGERED_ABILITIES_BY_SPELL_ID)) {
expect(ability.dbcSpellId, spellId).toBe(Number(spellId));
expect(ability.effects.length, spellId).toBeGreaterThan(0);
+9 -2
View File
@@ -1,4 +1,5 @@
import type { ClassId, CoaClassId } from "../app/characterCatalog";
import { COA_FIRST_PARTY_CLASS_IDS } from "./coaFirstPartyRoster";
import type {
AbilityAmountScaling,
AbilityAuraControl,
@@ -158,7 +159,7 @@ export const COA_CLASS_RESOURCES: Readonly<Record<CoaClassId, ClassResourceProfi
guardian: { type: "energy", name: "Energy", color: "#d8c43f", maximum: 100, initial: 100, regenerationPerSecond: 10 },
templar: { type: "energy", name: "Energy / Holy Runes", color: "#00d989", maximum: 100, initial: 100, regenerationPerSecond: 10 },
bloodmage: { type: "rage", name: "Sanguine Rage", color: "#b74439", maximum: 100, initial: 0, regenerationPerSecond: 5 },
ranger: { type: "energy", name: "Focus", color: "#d8c43f", maximum: 100, initial: 100, regenerationPerSecond: 10 },
ranger: { type: "focus", name: "Focus", color: "#d8c43f", maximum: 100, initial: 100, regenerationPerSecond: 10 },
chronomancer: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
necromancer: { type: "mana", name: "Mana / Runic Power", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
pyromancer: { type: "mana", name: "Mana", color: "#397dcc", maximum: 100, initial: 100, regenerationPerSecond: 4 },
@@ -1341,9 +1342,12 @@ function progressionAbility(chain: CoaProgressionChain): AbilityDefinition | nul
}
function treeAbility(entry: CoaTreeEntry): AbilityDefinition | null {
if (entry.entryType !== "Ability") return null;
const classId = coaClassIdFromDbcId(entry.classId);
if (!classId) return null;
// The five-class milestone also needs actions purchased with Talent Essence.
// Entry type describes the purchase, not whether the learned spell is active.
const talentAction = entry.entryType === "Talent";
if (talentAction && !COA_FIRST_PARTY_CLASS_IDS.includes(classId)) return null;
const resource = COA_CLASS_RESOURCES[classId].type;
const ranks = Array.from({ length: Math.max(1, entry.maxPoints) }, (_, index) => {
const rank = coaEntryRank(entry, index + 1);
@@ -1366,6 +1370,9 @@ function treeAbility(entry: CoaTreeEntry): AbilityDefinition | null {
|| /\b(?:passive|specialization)\b/i.test(firstSpell?.rank ?? dbc?.rankText ?? "")
|| /^Deprecate(?:d)?\.?$/i.test((first.description || entry.description).trim())
;
// Passive Talent nodes retain their existing modifier path. Missing spell
// evidence must not turn a node into an invented action.
if (talentAction && (passive || !dbc)) return null;
return {
id: `coa-tree-${entry.id}`,
classId,
+150
View File
@@ -0,0 +1,150 @@
import { beforeEach, describe, expect, it } from "vitest";
import { COA_ABILITIES_BY_CLASS } from "./coaAbilityCatalog";
import { coaTreeEntryById } from "./coaLiveCatalog";
import {
COA_BARBARIAN_DECAPITATE_HEALTH_THRESHOLD,
COA_BARBARIAN_DECAPITATE_TALENT_ENTRY_ID,
coaBarbarianDecapitateTargetAllowed,
isCoaBarbarianDecapitate,
} from "./coaBarbarianExecuteRuntime";
import { useCombatStore } from "./combatStore";
import { evaluateCombatRotation, type RotationContext } from "./combatRotation";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
updatePartyRuntimeTargetVisibility,
} from "./partyRuntimeRegistry";
import { partyAbilityTargetIsValid } from "./partyRuntime";
import { createConfiguredPartyMember } from "./partyStore";
import { resourceProfileForClass } from "./abilityCatalog";
const TARGET_ID = "coa-decapitate-target";
const TALENT_RANKS = Object.freeze({ [`coa-entry-${COA_BARBARIAN_DECAPITATE_TALENT_ENTRY_ID}`]: 1 });
function decapitate() {
const ability = COA_ABILITIES_BY_CLASS.barbarian.find((candidate) => (
candidate.talentEntryId === COA_BARBARIAN_DECAPITATE_TALENT_ENTRY_ID
));
if (!ability) throw new Error("The source-backed CoA Barbarian Decapitate action is missing.");
return ability;
}
function setTargetHealth(health: number): void {
useCombatStore.setState((state) => ({
mobs: {
...state.mobs,
[TARGET_ID]: { ...state.mobs[TARGET_ID]!, health, dead: health <= 0 },
},
}));
}
function rotationContext(targetHealth: number): RotationContext {
const ability = decapitate();
const resource = resourceProfileForClass("barbarian").type;
return {
now: 1_000,
classId: "barbarian",
specializationId: "barbarian:brutality",
role: "damage",
level: 60,
health: 1_000,
maxHealth: 1_000,
resources: { [resource]: 10_000 },
maximumResources: { [resource]: 10_000 },
primaryResource: resource,
comboPoints: 0,
comboTargetId: null,
runes: [],
abilities: [ability],
hostileTarget: { id: TARGET_ID, health: targetHealth, maxHealth: 1_000 },
hostileCount: 1,
};
}
describe("CoA Barbarian Decapitate execute window", () => {
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
useCombatStore.getState().initializeCharacter({
classId: "barbarian",
specializationId: "barbarian:brutality",
role: "damage",
level: 60,
talentRanks: TALENT_RANKS,
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
useCombatStore.getState().registerMob(TARGET_ID, {
name: "Decapitate target",
maxHealth: 1_000,
xpReward: 0,
});
setMobPosition(TARGET_ID, [2, 0, 0]);
useCombatStore.getState().selectMob(TARGET_ID);
});
it("pins the execute threshold to the source-backed tree description and exact action identity", () => {
const source = coaTreeEntryById(COA_BARBARIAN_DECAPITATE_TALENT_ENTRY_ID);
expect(source?.rankDescriptions?.[0]).toMatchObject({ spellId: 804414 });
expect(source?.description).toContain("Only usable on enemies below 35% health.");
expect(COA_BARBARIAN_DECAPITATE_HEALTH_THRESHOLD).toBe(0.35);
const ability = decapitate();
expect(isCoaBarbarianDecapitate(ability)).toBe(true);
expect(coaBarbarianDecapitateTargetAllowed(ability, { health: 349, maxHealth: 1_000 })).toBe(true);
expect(coaBarbarianDecapitateTargetAllowed(ability, { health: 350, maxHealth: 1_000 })).toBe(false);
expect(coaBarbarianDecapitateTargetAllowed(ability, { health: 351, maxHealth: 1_000 })).toBe(false);
for (const target of [undefined, null, { health: 349 }, { health: 349, maxHealth: 0 },
{ health: 0, maxHealth: 1_000 }, { health: 349, maxHealth: 1_000, dead: true }]) {
expect(coaBarbarianDecapitateTargetAllowed(ability, target)).toBe(false);
}
expect(isCoaBarbarianDecapitate({ classId: "guardian", talentEntryId: 5744 })).toBe(false);
expect(isCoaBarbarianDecapitate({ classId: "barbarian", talentEntryId: 1234 })).toBe(false);
expect(coaBarbarianDecapitateTargetAllowed({ classId: "barbarian", talentEntryId: 1234 }, undefined)).toBe(true);
expect(coaBarbarianDecapitateTargetAllowed({ classId: "guardian", talentEntryId: 5744 }, undefined)).toBe(true);
});
it("rejects the player's boundary cast without spending resource or cooldown, then allows it below 35%", () => {
const ability = decapitate();
expect(useCombatStore.getState().talentRanks[`coa-entry-${ability.talentEntryId}`]).toBe(1);
setTargetHealth(350);
const before = useCombatStore.getState();
const rejected = useCombatStore.getState().castAbility(ability.id, { now: 1_000, targetActorId: TARGET_ID });
const after = useCombatStore.getState();
expect(rejected).toMatchObject({ ok: false, reason: "condition-not-met" });
expect(after.resourcePools).toEqual(before.resourcePools);
expect(after.cooldowns).toEqual(before.cooldowns);
expect(after.globalCooldownEndsAt).toBe(before.globalCooldownEndsAt);
setTargetHealth(349);
expect(useCombatStore.getState().castAbility(ability.id, { now: 2_000, targetActorId: TARGET_ID }))
.toMatchObject({ ok: true, abilityId: ability.id });
});
it("applies the same target boundary to a Barbarian companion", () => {
const ability = decapitate();
const companion = createConfiguredPartyMember({
name: "Brutality companion",
classId: "barbarian",
specialization: "Brutality",
specializationId: "barbarian:brutality",
talentTreeId: "barbarian:brutality",
talentRanks: TALENT_RANKS,
role: "damage",
}, 60, 0, "coa-decapitate-companion");
registerPartyRuntimePosition(companion.id, [0, 0, 0]);
updatePartyRuntimeTargetVisibility(companion.id, TARGET_ID, true);
setTargetHealth(350);
expect(partyAbilityTargetIsValid(companion, ability, TARGET_ID, undefined, 1_000)).toBe(false);
setTargetHealth(349);
expect(partyAbilityTargetIsValid(companion, ability, TARGET_ID, undefined, 1_000)).toBe(true);
});
it("keeps Decapitate out of the companion rotation above and at the boundary", () => {
const belowThreshold = evaluateCombatRotation(rotationContext(349));
expect(belowThreshold?.ability.id).toBe(decapitate().id);
expect(evaluateCombatRotation(rotationContext(350))?.ability.id).not.toBe(decapitate().id);
expect(evaluateCombatRotation(rotationContext(500))?.ability.id).not.toBe(decapitate().id);
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { AbilityDefinition } from "./abilityCatalog";
/** CoA tree node 5744 / source spell 804414: Decapitate is usable below 35% health. */
export const COA_BARBARIAN_DECAPITATE_TALENT_ENTRY_ID = 5744;
export const COA_BARBARIAN_DECAPITATE_HEALTH_THRESHOLD = 0.35;
type CoaBarbarianDecapitateIdentity = Pick<AbilityDefinition, "classId" | "talentEntryId">;
export interface CoaBarbarianExecuteTargetHealth {
readonly health?: number;
readonly maxHealth?: number;
readonly dead?: boolean;
}
export function isCoaBarbarianDecapitate(
ability: CoaBarbarianDecapitateIdentity | null | undefined,
): boolean {
return ability?.classId === "barbarian"
&& ability.talentEntryId === COA_BARBARIAN_DECAPITATE_TALENT_ENTRY_ID;
}
/** Other actions pass through; Decapitate requires a living, valid target strictly below 35%. */
export function coaBarbarianDecapitateTargetAllowed(
ability: CoaBarbarianDecapitateIdentity | null | undefined,
target: CoaBarbarianExecuteTargetHealth | null | undefined,
): boolean {
if (!isCoaBarbarianDecapitate(ability)) return true;
if (!target || target.dead === true
|| !Number.isFinite(target.health) || !Number.isFinite(target.maxHealth)
|| target.health! <= 0 || target.maxHealth! <= 0) return false;
return target.health! / target.maxHealth! < COA_BARBARIAN_DECAPITATE_HEALTH_THRESHOLD;
}
@@ -0,0 +1,100 @@
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { abilitiesForClass } from "./abilityCatalog";
import { useCombatStore } from "./combatStore";
import { usePartyStore } from "./partyStore";
import { advanceDungeonPartyCombat, resetPartyRuntimeClock } from "./partyRuntime";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import {
clearPartyRuntimeRegistry,
registerPartyRuntimePosition,
updatePartyRuntimeTargetVisibility,
} from "./partyRuntimeRegistry";
import {
createCoaFirstPartyCompanions,
loadCoaFirstPartyBuilds,
} from "./benchmark/coaFirstParty";
import type { BenchmarkBuildDefinition } from "./benchmark/benchmarkTypes";
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
describe("first CoA party base Potion Toss delivery", () => {
let builds: readonly BenchmarkBuildDefinition[];
beforeAll(async () => { builds = await loadCoaFirstPartyBuilds(60); });
beforeEach(() => {
clearMobRuntimeRegistry();
clearPartyRuntimeRegistry();
resetPartyRuntimeClock();
});
function initializeSeat(slot: number) {
const build = builds[slot];
useCombatStore.getState().initializeCharacter({
classId: build.classId,
specializationId: slot === 1 ? "witch-doctor:brewing" : "guardian:vanguard",
role: build.role,
level: build.level,
talentRanks: build.talentRanks,
});
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
usePartyStore.getState().initializeParty("coa-toss", 60, useCombatStore.getState().health, build.role);
const companions = createCoaFirstPartyCompanions(builds, slot, "coa-toss");
usePartyStore.setState({ members: companions });
for (const member of companions) registerPartyRuntimePosition(member.id, [1, 0, 0]);
useCombatStore.getState().synchronizeActors();
return companions;
}
it("lets a Brewing player heal the selected Vanguard companion with the learned Talent action", () => {
const companions = initializeSeat(1);
const tank = companions.find((member) => member.role === "tank")!;
const toss = abilitiesForClass("witch-doctor").find((ability) => ability.talentEntryId === 12646)!;
expect(useCombatStore.getState().talentRanks["coa-entry-12646"]).toBe(1);
usePartyStore.setState((state) => ({ members: state.members.map((member) => member.id === tank.id
? { ...member, health: Math.round(member.maxHealth / 2) } : member) }));
usePartyStore.getState().selectMember(tank.id);
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
const before = usePartyStore.getState().members.find((member) => member.id === tank.id)!.health;
const mana = useCombatStore.getState().resourcePools.mana;
const events: StructuredCombatEvent[] = [];
const stop = subscribeCombatEvents((event) => { if (event.kind === "healing") events.push(event); });
expect(useCombatStore.getState().castAbility(toss.id, undefined, 1_000)).toMatchObject({ ok: true });
if (toss.castTimeMs > 0) useCombatStore.getState().tick(0.1, 1_000 + toss.castTimeMs);
stop();
expect(events).toEqual(expect.arrayContaining([expect.objectContaining({
abilityId: toss.id, targetActorId: tank.id, effectiveAmount: expect.any(Number),
})]));
expect(usePartyStore.getState().members.find((member) => member.id === tank.id)!.health).toBeGreaterThan(before);
expect(useCombatStore.getState().resourcePools.mana).toBeLessThan(mana!);
});
it("lets the Brewing companion deliver the same base heal to the injured Vanguard player", () => {
initializeSeat(0);
const toss = abilitiesForClass("witch-doctor").find((ability) => ability.talentEntryId === 12646)!;
const before = Math.round(useCombatStore.getState().maxHealth / 2);
useCombatStore.setState({ health: before });
usePartyStore.setState((state) => ({ members: state.members.map((member) => member.role === "healer"
? { ...member, cooldowns: Object.fromEntries(abilitiesForClass("witch-doctor")
.filter((ability) => ability.id !== toss.id).map((ability) => [ability.id, 60_000])) }
: { ...member, nextActionAt: 60_000 }) }));
useCombatStore.getState().commitCompanionActors(usePartyStore.getState().members);
const healer = usePartyStore.getState().members.find((member) => member.role === "healer")!;
expect(healer.talentRanks["coa-entry-12646"]).toBe(1);
useCombatStore.getState().registerMob("coa-toss-target", { name: "Target", maxHealth: 100_000, xpReward: 0 });
setMobPosition("coa-toss-target", [2, 0, 0]);
useCombatStore.getState().damageMob("coa-toss-target", 1, 100);
for (const member of usePartyStore.getState().members) {
updatePartyRuntimeTargetVisibility(member.id, "coa-toss-target", true);
}
useCombatStore.getState().synchronizeActors();
const events: StructuredCombatEvent[] = [];
const stop = subscribeCombatEvents((event) => { if (event.kind === "healing") events.push(event); });
advanceDungeonPartyCombat(1_000);
if (toss.castTimeMs > 0) advanceDungeonPartyCombat(1_000 + toss.castTimeMs);
stop();
expect(events).toEqual(expect.arrayContaining([expect.objectContaining({
abilityId: toss.id, sourceActorId: healer.id, targetActorId: "__player__",
})]));
expect(useCombatStore.getState().health).toBeGreaterThan(before);
expect(usePartyStore.getState().members.find((member) => member.id === healer.id)!.resource)
.toBeLessThan(healer.resource);
});
});
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff. Show more