forked from phenom/healer-man
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f8cb8a510 | ||
|
|
9be6f54c52 | ||
|
|
6a716fb8ca | ||
|
|
b3e0445e9c | ||
|
|
72a67ac1e3 | ||
|
|
89b6f4f79d | ||
|
|
3687c423e2 | ||
|
|
a9322a3897 | ||
|
|
768a3275ea | ||
|
|
812162320d | ||
|
|
e567910c04 | ||
|
|
ab37006932 |
No files matched your search
@@ -5744,3 +5744,38 @@ unchanged; no commit, deployment or readiness promotion was performed.
|
||||
build; the actual prior dist has a separate exclusive backup.
|
||||
- Evidence: artifacts/playtest/rom-residual-review-20261006/; scope/limits and
|
||||
source/promotion/build proofs: docs/ROM_RESIDUAL_RENDERING_REVIEW_2026-10-06.md.
|
||||
|
||||
## Gear namespace checkpoint — native #292 / shared #9 (2026-10-10)
|
||||
|
||||
Character/class source category now governs item normalization, grants, equip
|
||||
and active stats independently from dungeon source. Shared profile schema 8
|
||||
migrates missing-category WoW records only for explicit WoW owners and retains
|
||||
foreign/unidentified/invalid RoM records plus assignments in recovery. Rejected
|
||||
updates leave stored bytes unchanged. Native ordinary loot stays disabled;
|
||||
boss cards and simulator reward policy retain their existing behavior.
|
||||
|
||||
253 unique focused source tests, full-scope app typecheck and release hygiene
|
||||
pass. The composed native bundle builds, 21 selected native cases pass, and five
|
||||
public Ready WoW level-80 snapshots match the pre-change bundle with controlled
|
||||
clock/RNG. This does not certify overall balance or equipped Ready gear.
|
||||
|
||||
No dungeon was entered for this checkpoint, so no new Cellblock Rush boss,
|
||||
GPU screenshot, browser roster/dungeon inspection, manual fight or listening
|
||||
result is claimed. The source browser build stops at missing ignored Manastorm
|
||||
assets; browser release/visual verification remains open. Native offline gear
|
||||
persistence, recovery presentation and authenticated RoM progression remain
|
||||
under #124. See docs/GAME_GEAR_NAMESPACES.md and gear-namespace-validation.json.
|
||||
|
||||
## AppleDouble input discovery — shared #11 (2026-10-10)
|
||||
|
||||
Vitest defaults now exclude ._-prefixed file/directory components while retaining
|
||||
its built-in exclusions. A portable fresh-fixture proof reproduces three binary
|
||||
sidecar transform failures in the unfiltered control; fixed defaults run both
|
||||
ordinary sentinels, including ordinary._fragment.test.ts. TypeScript API/CLI
|
||||
retain those two inputs. TypeScript 5.8 already skipped dot-prefixed roots, so
|
||||
its explicit exclusions preserve observed behavior. Vite/Node config typecheck
|
||||
passes. See docs/METADATA_DISCOVERY.md and metadata-discovery-validation.json.
|
||||
|
||||
This is source tooling; no native runtime install or new dungeon entry was
|
||||
performed. No observed Cellblock Rush boss, GPU capture, full app build,
|
||||
gameplay or balance certification is claimed. Existing metadata is retained.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Death Pact and Blood Gorged finding corrections
|
||||
|
||||
Tracked in [shared issue #19](https://git.whoagland.com/phenom/healer-man/issues/19).
|
||||
Coordinator: Root `/root`, session `01a12399-cfab-7c72-8455-e0e4cb7e156c`.
|
||||
|
||||
The local Death Pact row (48743) supplies a 40% heal, a 100-yard minion-selection
|
||||
radius, a 40 runic-power cost, a 120-second cooldown and a 1.5-second GCD.
|
||||
Its hostile instant-kill conversion and tooltip-derived flat heal were wrong.
|
||||
The explicit sacrifice effect now consumes exactly one living, unexpired undead
|
||||
minion owned by the caster inside that radius and heals only that caster.
|
||||
The heal cannot crit. Missing minions fail before resource, cooldown or GCD use.
|
||||
Sacrifice produces a death record rather than damage-meter or experience credit.
|
||||
Player and companion dispatch share minion selection and death-state mutation.
|
||||
|
||||
The [AzerothCore Death Pact handler](https://github.com/azerothcore/azerothcore-wotlk/blob/master/src/server/scripts/Spells/spell_dk.cpp)
|
||||
corroborates living, owned undead selection within 100 yards and a single victim.
|
||||
This runtime admits only the two undead ghoul entries it already models through
|
||||
Raise Dead (26125) and Army of the Dead (24207). Unknown summon families are not
|
||||
assumed undead. Other undead minion types require a modeled source identity.
|
||||
Automated non-healer sacrifices use the existing defensive health threshold;
|
||||
this is simulator AI policy, not a spell-source trigger.
|
||||
|
||||
Blood Gorged's five local ranks contain only learn-spell effects (36), linking
|
||||
the conditional damage and armor-penetration children. The catalog no longer
|
||||
invents an active attack from that passive tooltip. Talent wrappers with only
|
||||
learn effects and no activation cost, cast time or cooldown remain passive;
|
||||
active and mixed-effect spells remain executable. The generator preserves the
|
||||
same classification for future imports. Existing sourced Blood Gorged damage
|
||||
and armor handling remains in `wow335DeathKnightBloodGorgedRuntime.ts`; see
|
||||
`docs/WOW_DEATH_KNIGHT_BLOOD_GORGED.md` for its health and family-mask boundaries.
|
||||
|
||||
Focused validation covers real Raise Dead then Death Pact casts, hostile trash
|
||||
and bosses, owner isolation, dead/expired/distant/non-undead minions, exact
|
||||
cost/cooldown/GCD, overheal and noncritical event accounting, actual automatic
|
||||
companion dispatch, and all five Blood Gorged wrapper ranks. Existing player and
|
||||
companion Blood Gorged health/mask tests preserve the legitimate passive bonus.
|
||||
The historical 873-cell campaign and source snapshots are unchanged.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Gear namespaces and recoverable legacy records
|
||||
|
||||
The source runtime distinguishes an item's game namespace from the dungeon that
|
||||
produced its source row. `InventoryItem.contentCategory` is `wow`, `rom`, or
|
||||
`coa`; it stays optional in the TypeScript contract for legacy callers, while
|
||||
new WoW and Runewaker factories explicitly write their own source category.
|
||||
Saved character/class identity chooses the expected owner category. A dungeon
|
||||
selection cannot change it or relabel another game's gear.
|
||||
|
||||
## Item and equipment boundaries
|
||||
|
||||
`lootTypes.ts` exposes an explicit category match and a usability check.
|
||||
RoM usability additionally requires a nonempty payload accepted by the existing
|
||||
`normalizeRomNativeGearStats` normalizer. This is structural validation, not
|
||||
proof of an authenticated item association or authoritative stat ranges.
|
||||
Unidentified and foreign records cannot be equipped or contribute active gear.
|
||||
Contradictory owner-category hints cannot override the source class category.
|
||||
|
||||
Owner-aware loot generation and combat grants filter foreign/unusable records.
|
||||
An unavailable same-game item pool yields no borrowed gear. Boss-card identity
|
||||
continues to use the boss/dungeon source. Native Godot still remaps both loot
|
||||
catalogs to its cards-only adapter; this change does not enable ordinary loot.
|
||||
The generic Runewaker item catalog has source-tagged records without native
|
||||
gear payloads and is not promoted into a playable equipment progression pool.
|
||||
|
||||
Synthetic equipment scoring and internal training gear remain simulation
|
||||
scaffolding. They are not imported live loot. A private training-item identity
|
||||
can preserve existing simulator fallback calculations without authorizing a
|
||||
persisted or copied record as native RoM gear.
|
||||
|
||||
## Profile migration and recovery
|
||||
|
||||
`accountRepository.ts` reads local database versions 1–8 at the existing
|
||||
`healer-man.profile-database.v1` key and writes schema 8. A missing item category
|
||||
may migrate to `wow` only for an explicit WoW owner; the existing normalized
|
||||
numeric item fields and equipment references retain their prior values.
|
||||
Missing categories on RoM/CoA records are not guessed from names, IDs or dungeons.
|
||||
|
||||
Normalized active inventories and assignments contain usable same-game items.
|
||||
Rejected legacy records remain in character-local `gearRecovery.items` with
|
||||
original item data and a reason (`missing-category`, `foreign-category`, or
|
||||
`invalid-native-payload`). `gearRecovery.equipment` retains former owner/slot/
|
||||
instance references. Repeated load/cloud normalization merges recovery without
|
||||
multiplying it, and ordinary gear updates preserve the existing recovery.
|
||||
This schema retains data for a later explicit recovery operation; it does not
|
||||
provide a recovery UI or invent a replacement item.
|
||||
|
||||
Inventory/equipment mutations reject foreign or unusable incoming gear before
|
||||
writing. Rejected mutations leave stored bytes unchanged. Other profiles and
|
||||
owners retain separate inventories and recovery. Cloud serialization carries
|
||||
full profile rows, and hydration queues a save when namespace/recovery migration
|
||||
changes the roster; existing boss-card checkpoint and session-generation behavior
|
||||
remain separate.
|
||||
|
||||
## Native integration and remaining scope
|
||||
|
||||
Shared source issue [#9](https://git.whoagland.com/phenom/healer-man/issues/9)
|
||||
owns the source guards and profile schema. Native checkpoint
|
||||
[#292](https://git.whoagland.com/Codex/healer-man-godot/issues/292) owns reviewed
|
||||
source pins, bridge regressions and installation. Parent native
|
||||
[#124](https://git.whoagland.com/Codex/healer-man-godot/issues/124) remains open:
|
||||
native offline profiles currently store identity/appearance and do not persist
|
||||
runtime item inventory/loadout or expose recovery. RoM entry retains empty gear.
|
||||
Source-backed RoM progression and equipment presentation are not established by
|
||||
the source association catalog. CoA is Ascension Conquest of Azeroth, distinct
|
||||
from supplementary Chronicles of Arcadia references; no native CoA mode is added.
|
||||
|
||||
RoM stat formulas, weights and allocations remain under the separate native
|
||||
[#123](https://git.whoagland.com/Codex/healer-man-godot/issues/123) claim. The
|
||||
existing possibility of non-finite native gear aggregates is tracked separately
|
||||
in shared [#10](https://git.whoagland.com/phenom/healer-man/issues/10); namespace
|
||||
metadata does not establish numeric bounds or source authenticity.
|
||||
|
||||
## Verification
|
||||
|
||||
Validation results and exact inputs are recorded with the owning issues. The
|
||||
focused item/equipment suites pass 39 tests; account/migration/cloud suites pass
|
||||
35; combat/stat regressions pass 120; selected training/slot checks pass three;
|
||||
and the avatar suite passes 56 with verified copied inputs and explicit
|
||||
AppleDouble exclusions. The native source-boundary suite passes seven tests,
|
||||
and the candidate native bundle builds from the composed, pinned source.
|
||||
|
||||
The required browser production build currently stops at the missing
|
||||
`public/assets/game/manastorm` asset-audit input in the isolated review checkout.
|
||||
This is retained as an unmet browser build/visual verification limit; a passing
|
||||
native bundle does not certify the browser release. Source TypeScript and
|
||||
combined native/card/simulator checks are recorded separately. The repository
|
||||
release hygiene check passes. No new GPU capture, live browser roster/dungeon
|
||||
check, manual playthrough or listening is claimed.
|
||||
|
||||
The full source app TypeScript check passes with the original `src` scope and
|
||||
task-local exclusions for AppleDouble companion files. All 21 selected native
|
||||
cases pass across the unchanged 20-case run and the exact missing-catalog case
|
||||
rerun. A serial public-runtime comparison matches all five Ready WoW profiles
|
||||
at level 80 with identical clock and random inputs. Those profiles have empty
|
||||
gear; equipped-item and migration behavior is covered by the focused source
|
||||
suites. Earlier varying mana results came from run-ID-seeded party identities,
|
||||
not a gear formula change; the superseded controls remain in the archive.
|
||||
|
||||
See `gear-namespace-validation.json` for exact source and evidence identities.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Issue 18: maintenance cast selection
|
||||
|
||||
The benchmark controller scores a generic candidate list alongside the shared
|
||||
rotation. A maintained aura rejected by the shared selector must also be
|
||||
removed from that fallback list, or the next benchmark decision can cast it
|
||||
again.
|
||||
|
||||
Current, unexpired auras are passed to the selector with their actual target
|
||||
and aura ID. The selector suppresses the same applied aura only on the target
|
||||
the candidate would use. An aura on another actor does not block the cast, and
|
||||
a different aura in the same exclusive group remains eligible to replace it.
|
||||
Expired and dispelled auras are absent from the active snapshot. Periodic
|
||||
effects retain their source, target, and remaining-duration checks. An attack
|
||||
or heal that also applies an aura remains eligible for its primary effect.
|
||||
|
||||
Focused regressions cover Protection Paladin L60 Greater Blessing of Sanctuary,
|
||||
RoM Scout L30 Detection, target-scoped aura selection, and exclusive-group
|
||||
replacement. Run them with:
|
||||
|
||||
```sh
|
||||
./node_modules/.bin/vitest run src/game/benchmark/benchmarkController.maintenance.test.ts src/game/combatRotation.test.ts --reporter=dot
|
||||
```
|
||||
@@ -0,0 +1,53 @@
|
||||
# macOS metadata and source discovery
|
||||
|
||||
macOS can create AppleDouble companions whose path components start with `._`.
|
||||
These files contain filesystem metadata and are not application source. On the
|
||||
external-drive review checkout, the former Vitest include pattern selected
|
||||
`src/avatar/._*.test.ts` files and attempted to transform their binary contents.
|
||||
TypeScript 5.8 already skips the tested dot-prefixed paths with its existing
|
||||
`src` include. Its new explicit exclusions document the same metadata policy
|
||||
while preserving compiler options and ordinary input discovery.
|
||||
|
||||
Shared source issue [#11](https://git.whoagland.com/phenom/healer-man/issues/11)
|
||||
owns the default discovery correction:
|
||||
|
||||
- `vite.config.ts` retains `configDefaults.exclude` and adds `**/._*` plus
|
||||
`**/._*/**` to the test exclusions.
|
||||
- `tsconfig.app.json` retains its compiler options and `include: ["src"]`.
|
||||
Its exclusions retain the package-directory defaults and add the same
|
||||
metadata patterns.
|
||||
|
||||
The two patterns cover companion files and files inside a companion directory,
|
||||
including nested directories. A component must start with `._`; ordinary
|
||||
underscore names and names containing those characters elsewhere remain source.
|
||||
Existing metadata files are retained. This changes root input discovery;
|
||||
TypeScript's normal handling of explicit imports still applies.
|
||||
|
||||
`tools/verify-appledouble-metadata-discovery.mjs` supplies a focused regression
|
||||
proof through actual Vitest discovery/execution and TypeScript configuration
|
||||
input parsing. It uses fresh task-owned fixtures to verify the rejection path
|
||||
and two ordinary test sentinels to verify continued execution. It does not certify
|
||||
full application typechecking, a browser production build, gameplay, balance,
|
||||
character rendering or dungeon behavior.
|
||||
|
||||
Exact commands, input identities and observed results accompany the issue and
|
||||
`metadata-discovery-validation.json`. Shared source issue
|
||||
[#9](https://git.whoagland.com/phenom/healer-man/issues/9) still owns its separate
|
||||
browser build/live visual acceptance gap. Native checkpoint
|
||||
[#292](https://git.whoagland.com/Codex/healer-man-godot/issues/292) is already
|
||||
installed and merged. This tooling change requires no native runtime rebuild:
|
||||
the native writable source mirror is a subset without these browser configs.
|
||||
|
||||
Run the focused proof after installing the repository dependencies:
|
||||
|
||||
```sh
|
||||
node tools/verify-appledouble-metadata-discovery.mjs --evidence-dir /absolute/fresh-path
|
||||
```
|
||||
|
||||
Use a fresh absolute path outside the checkout. The helper keeps its fixtures
|
||||
and reports; it refuses existing destinations, overlap and symlink paths. It
|
||||
works from another current directory and uses the Node executable that launched
|
||||
it. The final external-drive control records three binary metadata transform
|
||||
failures; fixed Vitest defaults run both ordinary tests. TypeScript API and CLI
|
||||
resolve the same two ordinary inputs, with and without the explicit metadata
|
||||
filters. The Vite/Node configuration typecheck passes.
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"date": "2026-10-10",
|
||||
"coordinator": "/root",
|
||||
"session": "01a11cd3-fa7c-7510-ba8f-bc7a3d0310a6",
|
||||
"issues": {
|
||||
"native": 292,
|
||||
"nativeParent": 124,
|
||||
"shared": 9
|
||||
},
|
||||
"status": "tested-native-checkpoint-browser-verification-incomplete",
|
||||
"sourceFiles": {
|
||||
"src/game/lootTypes.ts": "36b11682b214bc479267075c720cb4afcdbb5c8ba7ac18c199d706c3f1313baa",
|
||||
"src/game/equipment.ts": "76a9757a9727cb7fa543695d8f77426ab1328688a65f4f2212b6c144afb16f92",
|
||||
"src/game/lootCatalog.ts": "7027dadc2f7f90f6e7b65603b38873b3ad32e09fb4807ceea541caa39278a5f5",
|
||||
"src/game/runewakerLootCatalog.ts": "113008a954c27611e4cc28576290fc721f254dee41427285a355f8e30ba70b9a",
|
||||
"src/game/lootPolicy.ts": "bded57df5de902ba01c365ac021709f572aeb692a7cc8a6c0edfd6494d8cad9d",
|
||||
"src/game/combatStore.ts": "f49b7af337af0fb6e69f6df68ec5ec9ab558cc88fe819ae30c4cf9ad823cd4e5",
|
||||
"src/game/heirloomEquipment.ts": "057da9166c2a29086fc8ef59afc81fb985ee52926b6cf2f91212809c987d0ff8",
|
||||
"src/app/accountRepository.ts": "32f6f63537db2312e553dee4c00cbb24ec04b1b0dc5d3079227a8e30d932f8e3",
|
||||
"src/app/types.ts": "41bb6aac2fd03b7c8c4c4c8e6b475bec4df5d3422627a61489077feada34b628",
|
||||
"src/app/onlineAccountClient.ts": "04eac8950a503ffccebf29f73f981c1d01a9e558e4712200aaf4ff534279acfe"
|
||||
},
|
||||
"composedNativeSourceFiles": {
|
||||
"src/game/lootTypes.ts": "36b11682b214bc479267075c720cb4afcdbb5c8ba7ac18c199d706c3f1313baa",
|
||||
"src/game/equipment.ts": "76a9757a9727cb7fa543695d8f77426ab1328688a65f4f2212b6c144afb16f92",
|
||||
"src/game/lootCatalog.ts": "7027dadc2f7f90f6e7b65603b38873b3ad32e09fb4807ceea541caa39278a5f5",
|
||||
"src/game/runewakerLootCatalog.ts": "113008a954c27611e4cc28576290fc721f254dee41427285a355f8e30ba70b9a",
|
||||
"src/game/lootPolicy.ts": "bded57df5de902ba01c365ac021709f572aeb692a7cc8a6c0edfd6494d8cad9d",
|
||||
"src/game/combatStore.ts": "5afefb58b9a02b564cf07140c19388163a8423d22695f3619d196a9d603eeb7d",
|
||||
"src/game/heirloomEquipment.ts": "057da9166c2a29086fc8ef59afc81fb985ee52926b6cf2f91212809c987d0ff8",
|
||||
"src/app/accountRepository.ts": "32f6f63537db2312e553dee4c00cbb24ec04b1b0dc5d3079227a8e30d932f8e3",
|
||||
"src/app/types.ts": "41bb6aac2fd03b7c8c4c4c8e6b475bec4df5d3422627a61489077feada34b628",
|
||||
"src/app/onlineAccountClient.ts": "04eac8950a503ffccebf29f73f981c1d01a9e558e4712200aaf4ff534279acfe"
|
||||
},
|
||||
"nativeEngineSha256": "6f9a06c8fb46dbc25b5ff7aa1fee77fcc950a8481e2beb8eb9ed69e33d51e15d",
|
||||
"checks": {
|
||||
"sourceFocusedUniqueTests": 253,
|
||||
"sourceGroups": {
|
||||
"itemEquipment": 39,
|
||||
"profileCloud": 35,
|
||||
"combatStats": 120,
|
||||
"targetedTraining": 3,
|
||||
"avatar": 56
|
||||
},
|
||||
"sourceAppTypecheck": "pass: full original src scope; task-local AppleDouble exclusions only",
|
||||
"sourceReleaseHygiene": "pass",
|
||||
"sourceBrowserBuild": "blocked: missing public/assets/game/manastorm in isolated review checkout",
|
||||
"nativeBundleBuild": "pass",
|
||||
"nativeFocusedCases": 21,
|
||||
"nativeCoverageMethod": "20 unchanged cases pass in attempt-03; the one missing-catalog case passes in the exact-case rerun after copying and pinning the installed boss-card catalog",
|
||||
"readyWowParity": "5/5 level-80 public Ready profiles match with fixed clock/RNG and one fresh process per profile; empty gear only",
|
||||
"liveBrowserRosterAndDungeon": "not performed",
|
||||
"newGodotGpuCapture": "not performed"
|
||||
},
|
||||
"artifacts": {
|
||||
"diagnostics/source-worker/attempt-08/source-worker-receipt.json": "8bcae5d6e47e7e3547ac72d5f3d650fa2f7280993f96a77a3a597fd32e696a48",
|
||||
"diagnostics/persistence-worker/final-receipt-attempt-04.json": "241b8423795b3596b3f77fcc93c0d50bf093f0dcfa6043ec9974c1c8050d1b2a",
|
||||
"diagnostics/required-source-checks/combat-regressions-attempt-01/result.json": "8def574f70b9b19ff57f4413eb4382ecd1e29df126d1036173dd092974021e2d",
|
||||
"diagnostics/required-source-checks/avatar-attempt-03/result.json": "74b511dc10bc233f4aaf49c3bf5a5eb6a626e5639b85332b1444a8d50ec08157",
|
||||
"diagnostics/required-source-checks/typecheck-attempt-04/result.json": "cb2f6f49e3f8f2f3c3f44ce02e0a0f644bcb9d69dfe656ba04b78cfb2af517ca",
|
||||
"diagnostics/required-source-checks/release-attempt-01/result.json": "1c0cd604233dfc3ed01ddca47b34770392e917cd5a589efee15a46d1ceb4c230",
|
||||
"diagnostics/required-source-checks/build-attempt-02/result.json": "d068f38485c7a38af27586d1bbd1ad96db9630bf976092da0572fe0c3a7d5255",
|
||||
"diagnostics/native-build/baseline-attempt-02/result.json": "92bf94d5b26fa99c502575d2fb9bd624518f9e5e21b362b697324efd6a6e6d63",
|
||||
"diagnostics/native-build/candidate-attempt-01/result.json": "05bf46066a9429edb2405d4a60ca7937446f1cb978e71e504a1bf0a051b3dbfb",
|
||||
"diagnostics/native-regressions/attempt-03/result.json": "7e31c04b48f9b01e1419c718023ad929a65cc0efdc3c4759e00a3b72e5d6775e",
|
||||
"diagnostics/native-regressions/cards-catalog-rerun-attempt-01/result.json": "23b8d1b0216de53c462ca25ada0443aa9bdd62336dd554b10bc5f0b9dfa19fcf",
|
||||
"diagnostics/persistence-worker/wow-preservation/attempt-10/acceptance-receipt.json": "8533744d44af288149572f7147c69961b50315101e3c7f86e90ee4d0bafc99ea",
|
||||
"diagnostics/native-candidate-integration/attempt-02/result.json": "44d4672f6f1c174563e2524fffe38ab18f1d96b74486b7a0c198bad764990fbe",
|
||||
"diagnostics/native-candidate-integration/attempt-02/hunk-preservation.json": "b2f6c6e236dc6484fd14ee9bada39fd60933b33320b927e721370feff8445e44",
|
||||
"diagnostics/required-source-checks/release-attempt-02/result.json": "daf8d3a2bc4dd0d103127687d6fd44ba5ee30f7e2ff51f518fb88fe80004caa6"
|
||||
},
|
||||
"preservedScopes": [
|
||||
"WoW rigs/animations/models/stats and installed short-GCD work",
|
||||
"#123 RoM formulas/weights/allocation",
|
||||
"native cards-only loot and simulator reward policy",
|
||||
"character category independent from dungeon category"
|
||||
],
|
||||
"remaining": [
|
||||
"Full browser build and live roster/in-dungeon screenshots",
|
||||
"#124 native offline item/loadout persistence and recovery UI",
|
||||
"#124 authenticated RoM item progression and equipment presentation",
|
||||
"Shared #10 non-finite aggregate follow-up"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"date": "2026-10-10",
|
||||
"issue": "https://git.whoagland.com/phenom/healer-man/issues/11",
|
||||
"coordinator": "/root",
|
||||
"session": "01a11cd3-fa7c-7510-ba8f-bc7a3d0310a6",
|
||||
"sourceBase": "812162320d9bf041a1b635b553576962238e5138",
|
||||
"files": {
|
||||
"vite.config.ts": "c394d03344dd8cef5f5488e00e892e3a53b84d2374068688fdb1459114e5627c",
|
||||
"tsconfig.app.json": "5cde2a8b2bd61121fbac7051ce403475ab487314b3970cadde8733ff5013292c",
|
||||
"tools/verify-appledouble-metadata-discovery.mjs": "7ffc8b265d589c65e74122bd90062cc75413b52ffa4bb1daf737a22fcb128674"
|
||||
},
|
||||
"proof": {
|
||||
"path": "diagnostics/metadata-validation/attempt-08/report.json",
|
||||
"sha256": "fa33b3b0bbbd4ce1f6141319ebe2a4f8b465aa408c865a8ea259cc8a2ca6f30e",
|
||||
"portableAlternateCwd": "/Volumes/backup1tb-2",
|
||||
"toolVersions": {
|
||||
"vitest": "3.2.7",
|
||||
"vite": "7.3.6",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"controlBinaryMetadataFailures": 3,
|
||||
"candidateVitestOrdinaryTests": 2,
|
||||
"candidateTypeScriptApiAndCliOrdinaryInputs": 2,
|
||||
"metadataFileAndNestedDirectoryExcluded": true,
|
||||
"ordinaryDotUnderscoreFragmentRetained": true,
|
||||
"vitestDefaultsRetained": true,
|
||||
"typescriptBaselineAlreadySkippedDotPaths": true
|
||||
},
|
||||
"nodeConfigTypecheck": {
|
||||
"passed": true,
|
||||
"receiptSha256": "c2dee7d831ab89628589267892058210f700f96ce69af6fe05f5b7ecaa33f29b"
|
||||
},
|
||||
"limits": [
|
||||
"Root input discovery only; explicit imports use normal compiler rules",
|
||||
"No full application typecheck/build, balance, gameplay or graphics certification",
|
||||
"TypeScript explicit exclusions preserve observed TS5.8 behavior",
|
||||
"Native input mirror lacks browser configs; no native runtime installation",
|
||||
"Existing metadata files and other workers edits retained"
|
||||
]
|
||||
}
|
||||
@@ -396,7 +396,11 @@ try {
|
||||
// with the same name on the class skill line. That proc has no player
|
||||
// activation mechanics, so retain it for the spellbook but classify it
|
||||
// as passive instead of exposing it as a castable action.
|
||||
const passive = passiveTalentNames.has(name) && ranks.every((spell) => (
|
||||
const passive = (passiveTalentNames.has(name) || (Boolean(talentNodeId)
|
||||
&& ranks.every((spell) => {
|
||||
const effects = spell.effects.filter((effect) => effect.effectType !== 0);
|
||||
return effects.length > 0 && effects.every((effect) => effect.effectType === 36);
|
||||
}))) && ranks.every((spell) => (
|
||||
spell.castTimeMs === 0
|
||||
&& spell.recoveryTimeMs === 0
|
||||
&& spell.categoryRecoveryTimeMs === 0
|
||||
|
||||
@@ -164,6 +164,7 @@ describe("local profile repository", () => {
|
||||
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
|
||||
}, storage).character!;
|
||||
const item = {
|
||||
contentCategory: "rom" as const,
|
||||
instanceId: "kept-item",
|
||||
itemId: 10412,
|
||||
itemKey: "belt-of-the-fang",
|
||||
@@ -174,7 +175,7 @@ describe("local profile repository", () => {
|
||||
weaponType: null,
|
||||
itemLevel: 17,
|
||||
sourceItemLevel: 21,
|
||||
stats: { ...EMPTY_ITEM_STATS, agility: 4, stamina: 4, armor: 15 },
|
||||
stats: { ...EMPTY_ITEM_STATS, agility: 4, stamina: 4, armor: 15, romNative: { strength: 1 } },
|
||||
quantity: 1,
|
||||
sourceBossKey: "lady-anacondra",
|
||||
sourceBossName: "Lady Anacondra",
|
||||
@@ -483,7 +484,7 @@ describe("local profile repository", () => {
|
||||
}));
|
||||
expect(loadCombatPresentationSettings("offline-roster", storage))
|
||||
.toEqual(DEFAULT_COMBAT_PRESENTATION_SETTINGS);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(8);
|
||||
}
|
||||
|
||||
const legacyV3Storage = new MemoryStorage();
|
||||
@@ -642,16 +643,176 @@ describe("local profile repository", () => {
|
||||
acquiredAt: 5_000,
|
||||
};
|
||||
|
||||
expect(updateCharacterInventory(offline.ownerId, character.id, [item], storage)?.inventory).toEqual([item]);
|
||||
const normalizedItem = { ...item, contentCategory: "wow" as const };
|
||||
expect(updateCharacterInventory(offline.ownerId, character.id, [item], storage)?.inventory).toEqual([normalizedItem]);
|
||||
expect(updateCharacterEquipment(offline.ownerId, character.id, {
|
||||
player: { waist: item.instanceId },
|
||||
}, storage)?.equipment).toEqual({ player: { waist: item.instanceId } });
|
||||
expect(listCharacters(offline.ownerId, storage)[0]?.inventory).toEqual([item]);
|
||||
expect(listCharacters(offline.ownerId, storage)[0]?.inventory).toEqual([normalizedItem]);
|
||||
expect(listCharacters(offline.ownerId, storage)[0]?.equipment).toEqual({
|
||||
player: { waist: item.instanceId },
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates foreign and unresolved gear into character-scoped recovery without changing owner identity", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const makeItem = (instanceId: string, contentCategory?: "wow" | "rom" | "coa", romNative?: Record<string, number>) => ({
|
||||
...(contentCategory ? { contentCategory } : {}),
|
||||
instanceId,
|
||||
itemId: 10412,
|
||||
itemKey: `gear:${instanceId}`,
|
||||
name: `Gear ${instanceId}`,
|
||||
quality: "rare" as const,
|
||||
inventorySlot: "waist",
|
||||
armorType: "leather",
|
||||
weaponType: null,
|
||||
itemLevel: 17,
|
||||
sourceItemLevel: 21,
|
||||
stats: {
|
||||
...EMPTY_ITEM_STATS,
|
||||
agility: 4,
|
||||
...(romNative === undefined ? {} : { romNative }),
|
||||
},
|
||||
quantity: 1,
|
||||
sourceBossKey: "source-boss",
|
||||
sourceBossName: "Source Boss",
|
||||
acquiredAt: 5_000,
|
||||
});
|
||||
const legacyProfile = (
|
||||
id: string,
|
||||
ownerId: string,
|
||||
classId: string,
|
||||
raceId: string,
|
||||
inventory: unknown[],
|
||||
equipment: unknown = {},
|
||||
) => ({
|
||||
id, ownerId, name: id, classId, raceId, gender: "female", appearance: {},
|
||||
inventory, equipment, location: "Dungeon Entrance", createdAt: 1, lastPlayedAt: 2,
|
||||
});
|
||||
const wowItem = makeItem("wow-legacy");
|
||||
const romMissingCategory = makeItem("rom-missing-category");
|
||||
const romForeign = makeItem("rom-foreign", "wow");
|
||||
const romInvalidNative = makeItem("rom-invalid-native", "rom", {});
|
||||
const coaMissingCategory = makeItem("coa-missing-category");
|
||||
const untouchedWow = makeItem("other-owner-item", "wow");
|
||||
const wowProfile = legacyProfile("wow-profile", "owner-one", "priest", "human", [wowItem], {
|
||||
player: { waist: wowItem.instanceId },
|
||||
});
|
||||
const romProfile = legacyProfile("rom-profile", "owner-one", "rom-priest", "rom-human", [
|
||||
romMissingCategory, romForeign, romInvalidNative,
|
||||
], {
|
||||
player: { waist: romMissingCategory.instanceId },
|
||||
companion: { waist: romForeign.instanceId },
|
||||
reserve: { waist: romInvalidNative.instanceId },
|
||||
});
|
||||
const coaProfile = legacyProfile("coa-profile", "owner-one", "barbarian", "human", [coaMissingCategory]);
|
||||
const otherOwnerProfile = legacyProfile("other-owner-profile", "owner-two", "priest", "human", [untouchedWow]);
|
||||
storage.setItem(PROFILE_STORAGE_KEYS.database, JSON.stringify({
|
||||
version: 7,
|
||||
accounts: [],
|
||||
rosters: { "owner-one": [wowProfile, romProfile, coaProfile], "owner-two": [otherOwnerProfile] },
|
||||
bossCardsByOwner: {},
|
||||
presentationSettingsByOwner: {},
|
||||
}));
|
||||
|
||||
const ownerOne = listCharacters("owner-one", storage);
|
||||
const wow = ownerOne.find((character) => character.id === "wow-profile")!;
|
||||
const rom = ownerOne.find((character) => character.id === "rom-profile")!;
|
||||
const coa = ownerOne.find((character) => character.id === "coa-profile")!;
|
||||
expect(wow.inventory).toMatchObject([{ instanceId: "wow-legacy", contentCategory: "wow", stats: { agility: 4 } }]);
|
||||
expect(wow.equipment).toEqual({ player: { waist: "wow-legacy" } });
|
||||
expect(rom.inventory).toEqual([]);
|
||||
expect(rom.equipment).toEqual({});
|
||||
expect(rom.gearRecovery?.items).toEqual([
|
||||
{ item: romMissingCategory, reason: "missing-category" },
|
||||
{ item: romForeign, reason: "foreign-category" },
|
||||
{ item: romInvalidNative, reason: "invalid-native-payload" },
|
||||
]);
|
||||
expect(rom.gearRecovery?.equipment).toEqual([
|
||||
{ ownerId: "player", slot: "waist", instanceId: "rom-missing-category", reason: "missing-category" },
|
||||
{ ownerId: "companion", slot: "waist", instanceId: "rom-foreign", reason: "foreign-category" },
|
||||
{ ownerId: "reserve", slot: "waist", instanceId: "rom-invalid-native", reason: "invalid-native-payload" },
|
||||
]);
|
||||
expect(coa.inventory).toEqual([]);
|
||||
expect(coa.gearRecovery?.items).toEqual([{ item: coaMissingCategory, reason: "missing-category" }]);
|
||||
expect(listCharacters("owner-two", storage)[0]?.inventory).toMatchObject([
|
||||
{ instanceId: "other-owner-item", contentCategory: "wow" },
|
||||
]);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(8);
|
||||
|
||||
const validRomReplacement = makeItem("valid-rom-replacement", "rom", { strength: 2 });
|
||||
const ordinarySave = updateCharacterInventory("owner-one", rom.id, [validRomReplacement], storage)!;
|
||||
expect(ordinarySave.inventory).toMatchObject([{ instanceId: "valid-rom-replacement", contentCategory: "rom" }]);
|
||||
expect(ordinarySave.gearRecovery).toEqual(rom.gearRecovery);
|
||||
|
||||
const migratedBytes = storage.getItem(PROFILE_STORAGE_KEYS.database);
|
||||
expect(listCharacters("owner-one", storage).find((character) => character.id === "rom-profile")?.gearRecovery)
|
||||
.toEqual(rom.gearRecovery);
|
||||
expect(storage.getItem(PROFILE_STORAGE_KEYS.database)).toBe(migratedBytes);
|
||||
|
||||
const withoutRemoteRecovery = { ...rom, gearRecovery: undefined };
|
||||
const replaced = replaceCharactersForOwner("owner-one", [withoutRemoteRecovery], storage);
|
||||
expect(replaced[0]?.gearRecovery).toEqual(rom.gearRecovery);
|
||||
const repeated = replaceCharactersForOwner("owner-one", [withoutRemoteRecovery], storage);
|
||||
expect(repeated[0]?.gearRecovery).toEqual(rom.gearRecovery);
|
||||
});
|
||||
|
||||
it("rejects foreign, unresolved, and unusable RoM inventory updates without writing partial state", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const rom = createCharacter({
|
||||
ownerId: "offline-rom",
|
||||
name: "Runesworn",
|
||||
categoryId: "rom",
|
||||
raceId: "rom-human",
|
||||
classId: "rom-priest",
|
||||
gender: "female",
|
||||
appearance: { skinColor: 0, face: 0, hairStyle: 0, hairColor: 0, feature: 0 },
|
||||
}, storage).character!;
|
||||
const item = (instanceId: string, contentCategory?: "wow" | "rom" | "coa", romNative?: Record<string, number>) => ({
|
||||
...(contentCategory ? { contentCategory } : {}),
|
||||
instanceId,
|
||||
itemId: 10412,
|
||||
itemKey: `gear:${instanceId}`,
|
||||
name: `Gear ${instanceId}`,
|
||||
quality: "rare" as const,
|
||||
inventorySlot: "waist",
|
||||
armorType: "leather",
|
||||
weaponType: null,
|
||||
itemLevel: 17,
|
||||
sourceItemLevel: 21,
|
||||
stats: {
|
||||
...EMPTY_ITEM_STATS,
|
||||
agility: 4,
|
||||
...(romNative === undefined ? {} : { romNative }),
|
||||
},
|
||||
quantity: 1,
|
||||
sourceBossKey: "source-boss",
|
||||
sourceBossName: "Source Boss",
|
||||
acquiredAt: 5_000,
|
||||
});
|
||||
const foreign = item("foreign", "wow");
|
||||
const unresolved = item("unresolved");
|
||||
const invalidNative = item("invalid-native", "rom", {});
|
||||
|
||||
for (const candidate of [foreign, unresolved, invalidNative]) {
|
||||
const before = storage.getItem(PROFILE_STORAGE_KEYS.database);
|
||||
expect(updateCharacterInventory(rom.ownerId, rom.id, [candidate], storage)).toBeNull();
|
||||
expect(storage.getItem(PROFILE_STORAGE_KEYS.database)).toBe(before);
|
||||
}
|
||||
|
||||
const valid = item("valid-rom", "rom", { strength: 3 });
|
||||
const updated = updateCharacterInventory(rom.ownerId, rom.id, [valid], storage)!;
|
||||
expect(updated.inventory).toMatchObject([{ instanceId: "valid-rom", contentCategory: "rom" }]);
|
||||
const beforeForeignEquipment = storage.getItem(PROFILE_STORAGE_KEYS.database);
|
||||
expect(updateCharacterEquipment(rom.ownerId, rom.id, {
|
||||
player: { waist: "foreign" },
|
||||
}, storage)).toBeNull();
|
||||
expect(storage.getItem(PROFILE_STORAGE_KEYS.database)).toBe(beforeForeignEquipment);
|
||||
expect(updateCharacterEquipment(rom.ownerId, rom.id, {
|
||||
player: { waist: "valid-rom" },
|
||||
}, storage)?.equipment).toEqual({ player: { waist: "valid-rom" } });
|
||||
});
|
||||
|
||||
it("persists account card copies independently of character creation and deletion", () => {
|
||||
const storage = new MemoryStorage();
|
||||
const offline = createOfflineSession();
|
||||
@@ -691,7 +852,7 @@ describe("local profile repository", () => {
|
||||
expect(loadAccountBossCards("one", storage)).toEqual(expected);
|
||||
expect(loadAccountBossCards("two", storage).counts.boss?.normal).toBe(4);
|
||||
const database = JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!);
|
||||
expect(database.version).toBe(7);
|
||||
expect(database.version).toBe(8);
|
||||
expect(database.rosters.one.every((entry: { bossCards?: unknown }) => entry.bossCards === undefined)).toBe(true);
|
||||
// An old cloud roster can be downloaded again without repeating the migration.
|
||||
replaceCharactersForOwner("one", legacyCharacters, storage);
|
||||
|
||||
+252
-17
@@ -1,4 +1,11 @@
|
||||
import type { CharacterProfile, PlayerSession } from "./types";
|
||||
import type {
|
||||
CharacterGearRecovery,
|
||||
CharacterProfile,
|
||||
GearRecoveryEquipmentReference,
|
||||
GearRecoveryItem,
|
||||
GearRecoveryReason,
|
||||
PlayerSession,
|
||||
} from "./types";
|
||||
import { mergeBossCardCollections, normalizeBossCardCollection, type BossCardCollection } from "../game/bossCards";
|
||||
import { normalizeUiPreferences, type UiPreferences } from "../game/uiLayout";
|
||||
import {
|
||||
@@ -11,7 +18,12 @@ import {
|
||||
orderedClassLoadoutKey,
|
||||
ROM_SECONDARY_UNLOCK_LEVEL,
|
||||
} from "./romDualClass";
|
||||
import { normalizeInventory, type InventoryItem } from "../game/lootTypes";
|
||||
import {
|
||||
inventoryItemMatchesCategory,
|
||||
inventoryItemUsableForCategory,
|
||||
normalizeInventory,
|
||||
type InventoryItem,
|
||||
} from "../game/lootTypes";
|
||||
import { normalizeEquipment, type EquipmentAssignments } from "../game/equipment";
|
||||
import type { GameplaySettings } from "../game/combatStore";
|
||||
import {
|
||||
@@ -70,7 +82,7 @@ interface LocalAccount {
|
||||
}
|
||||
|
||||
interface ProfileDatabase {
|
||||
version: 7;
|
||||
version: 8;
|
||||
accounts: LocalAccount[];
|
||||
rosters: Record<string, CharacterProfile[]>;
|
||||
bossCardsByOwner: Record<string, BossCardCollection>;
|
||||
@@ -114,7 +126,196 @@ function browserSessionStorage(): StorageLike | null {
|
||||
}
|
||||
|
||||
function emptyDatabase(): ProfileDatabase {
|
||||
return { version: 7, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} };
|
||||
return { version: 8, accounts: [], rosters: {}, bossCardsByOwner: {}, presentationSettingsByOwner: {} };
|
||||
}
|
||||
|
||||
const CONTENT_CATEGORY_IDS = new Set(["wow", "rom", "coa"]);
|
||||
|
||||
function isGearRecoveryReason(value: unknown): value is GearRecoveryReason {
|
||||
return value === "missing-category" || value === "foreign-category" || value === "invalid-native-payload";
|
||||
}
|
||||
|
||||
function normalizeGearRecovery(value: unknown): CharacterGearRecovery {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return { items: [], equipment: [] };
|
||||
const record = value as { items?: unknown; equipment?: unknown };
|
||||
const items: GearRecoveryItem[] = Array.isArray(record.items)
|
||||
? record.items.flatMap((entry): GearRecoveryItem[] => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)
|
||||
|| !Object.prototype.hasOwnProperty.call(entry, "item")) return [];
|
||||
const row = entry as { item: unknown; reason?: unknown };
|
||||
return [{
|
||||
item: row.item,
|
||||
reason: isGearRecoveryReason(row.reason) ? row.reason : "missing-category",
|
||||
}];
|
||||
})
|
||||
: [];
|
||||
const equipment: GearRecoveryEquipmentReference[] = Array.isArray(record.equipment)
|
||||
? record.equipment.flatMap((entry): GearRecoveryEquipmentReference[] => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
|
||||
const row = entry as Partial<GearRecoveryEquipmentReference>;
|
||||
if (typeof row.ownerId !== "string" || typeof row.slot !== "string" || typeof row.instanceId !== "string") return [];
|
||||
return [{
|
||||
ownerId: row.ownerId,
|
||||
slot: row.slot,
|
||||
instanceId: row.instanceId,
|
||||
reason: isGearRecoveryReason(row.reason) ? row.reason : "missing-category",
|
||||
}];
|
||||
})
|
||||
: [];
|
||||
return { items, equipment };
|
||||
}
|
||||
|
||||
function jsonIdentity(value: unknown): string {
|
||||
try { return JSON.stringify(value) ?? "undefined"; } catch { return "[unserializable]"; }
|
||||
}
|
||||
|
||||
/** Merges repeat cloud/profile normalization without duplicating the same recovered source row. */
|
||||
function mergeGearRecovery(
|
||||
...values: readonly unknown[]
|
||||
): CharacterGearRecovery | undefined {
|
||||
const mergedItems: GearRecoveryItem[] = [];
|
||||
const mergedEquipment: GearRecoveryEquipmentReference[] = [];
|
||||
const itemCounts = new Map<string, number>();
|
||||
const equipmentCounts = new Map<string, number>();
|
||||
for (const value of values) {
|
||||
const recovery = normalizeGearRecovery(value);
|
||||
const currentItemCounts = new Map<string, number>();
|
||||
for (const entry of recovery.items) {
|
||||
const key = `${entry.reason}\n${jsonIdentity(entry.item)}`;
|
||||
const count = (currentItemCounts.get(key) ?? 0) + 1;
|
||||
currentItemCounts.set(key, count);
|
||||
if (count > (itemCounts.get(key) ?? 0)) mergedItems.push(entry);
|
||||
}
|
||||
for (const [key, count] of currentItemCounts) {
|
||||
itemCounts.set(key, Math.max(itemCounts.get(key) ?? 0, count));
|
||||
}
|
||||
for (const entry of recovery.equipment) {
|
||||
const key = jsonIdentity(entry);
|
||||
if (equipmentCounts.has(key)) continue;
|
||||
equipmentCounts.set(key, 1);
|
||||
mergedEquipment.push(entry);
|
||||
}
|
||||
}
|
||||
return mergedItems.length || mergedEquipment.length
|
||||
? { items: mergedItems, equipment: mergedEquipment }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function recoveryReasonForCategory(
|
||||
category: unknown,
|
||||
ownerCategory: CharacterProfile["categoryId"],
|
||||
normalizedItem?: InventoryItem,
|
||||
): GearRecoveryReason {
|
||||
if (CONTENT_CATEGORY_IDS.has(String(category)) && category !== ownerCategory) return "foreign-category";
|
||||
if (category === "rom" && ownerCategory === "rom" && normalizedItem
|
||||
&& !inventoryItemUsableForCategory(normalizedItem, ownerCategory)) return "invalid-native-payload";
|
||||
return "missing-category";
|
||||
}
|
||||
|
||||
function incomingInventoryMatchesCategory(
|
||||
inventory: readonly InventoryItem[],
|
||||
ownerCategory: NonNullable<CharacterProfile["categoryId"]>,
|
||||
): boolean {
|
||||
for (const candidate of inventory as readonly unknown[]) {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return false;
|
||||
const item = candidate as Record<string, unknown>;
|
||||
const category = item.contentCategory;
|
||||
if (!(category === undefined && ownerCategory === "wow")
|
||||
&& !inventoryItemMatchesCategory(item as Pick<InventoryItem, "contentCategory">, ownerCategory)) return false;
|
||||
const normalized = normalizeInventory([candidate], { ownerCategory })[0];
|
||||
if (!normalized || !inventoryItemUsableForCategory(normalized, ownerCategory)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function equipmentReferencesRecoveredItem(
|
||||
assignments: EquipmentAssignments,
|
||||
recovery: CharacterGearRecovery | undefined,
|
||||
): boolean {
|
||||
if (!assignments || typeof assignments !== "object" || Array.isArray(assignments)) return false;
|
||||
if (!recovery?.items.length && !recovery?.equipment.length) return false;
|
||||
const recoveredIds = new Set<string>();
|
||||
for (const entry of recovery.items) {
|
||||
if (entry.item && typeof entry.item === "object" && !Array.isArray(entry.item)) {
|
||||
const instanceId = (entry.item as { instanceId?: unknown }).instanceId;
|
||||
if (typeof instanceId === "string") recoveredIds.add(instanceId);
|
||||
}
|
||||
}
|
||||
for (const entry of recovery.equipment) recoveredIds.add(entry.instanceId);
|
||||
return Object.values(assignments).some((loadout) => (
|
||||
loadout && typeof loadout === "object"
|
||||
&& Object.values(loadout).some((instanceId) => typeof instanceId === "string" && recoveredIds.has(instanceId))
|
||||
));
|
||||
}
|
||||
|
||||
function equipmentReferencesUsableInventory(
|
||||
assignments: EquipmentAssignments,
|
||||
profile: CharacterProfile,
|
||||
): boolean {
|
||||
if (!assignments || typeof assignments !== "object" || Array.isArray(assignments)) return false;
|
||||
const itemById = new Map(profile.inventory.map((item) => [item.instanceId, item]));
|
||||
for (const loadout of Object.values(assignments as Record<string, unknown>)) {
|
||||
if (!loadout || typeof loadout !== "object" || Array.isArray(loadout)) return false;
|
||||
for (const rawId of Object.values(loadout as Record<string, unknown>)) {
|
||||
if (typeof rawId !== "string") continue;
|
||||
const item = itemById.get(rawId);
|
||||
if (!item || !inventoryItemUsableForCategory(item, profile.categoryId)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface ProfileGearNormalization {
|
||||
inventory: InventoryItem[];
|
||||
equipment: EquipmentAssignments;
|
||||
gearRecovery?: CharacterGearRecovery;
|
||||
}
|
||||
|
||||
function normalizeProfileGear(
|
||||
rawInventory: unknown,
|
||||
rawEquipment: unknown,
|
||||
ownerCategory: NonNullable<CharacterProfile["categoryId"]>,
|
||||
existingRecovery: unknown,
|
||||
): ProfileGearNormalization {
|
||||
const usableInventory: InventoryItem[] = [];
|
||||
const recoveredItems: GearRecoveryItem[] = [];
|
||||
const rejectedInstanceIds = new Map<string, GearRecoveryReason>();
|
||||
const candidates = Array.isArray(rawInventory) ? rawInventory : [];
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
|
||||
const normalized = normalizeInventory([candidate], { ownerCategory })[0];
|
||||
if (normalized && inventoryItemUsableForCategory(normalized, ownerCategory)) {
|
||||
usableInventory.push(normalized);
|
||||
continue;
|
||||
}
|
||||
const raw = candidate as Record<string, unknown>;
|
||||
// Keep the old invalid-row behavior for an item that already claims this
|
||||
// profile's namespace but cannot be normalized as an InventoryItem.
|
||||
if (!normalized && raw.contentCategory === ownerCategory) continue;
|
||||
const reason = recoveryReasonForCategory(raw.contentCategory, ownerCategory, normalized);
|
||||
recoveredItems.push({ item: candidate, reason });
|
||||
if (typeof raw.instanceId === "string" && !rejectedInstanceIds.has(raw.instanceId)) {
|
||||
rejectedInstanceIds.set(raw.instanceId, reason);
|
||||
}
|
||||
}
|
||||
|
||||
const recoveredEquipment: GearRecoveryEquipmentReference[] = [];
|
||||
if (rawEquipment && typeof rawEquipment === "object" && !Array.isArray(rawEquipment)) {
|
||||
for (const [ownerId, rawLoadout] of Object.entries(rawEquipment as Record<string, unknown>)) {
|
||||
if (!ownerId || !rawLoadout || typeof rawLoadout !== "object" || Array.isArray(rawLoadout)) continue;
|
||||
for (const [slot, rawInstanceId] of Object.entries(rawLoadout as Record<string, unknown>)) {
|
||||
if (typeof rawInstanceId !== "string") continue;
|
||||
const reason = rejectedInstanceIds.get(rawInstanceId);
|
||||
if (reason) recoveredEquipment.push({ ownerId, slot, instanceId: rawInstanceId, reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
const equipment = normalizeEquipment(rawEquipment, usableInventory, ownerCategory);
|
||||
return {
|
||||
inventory: usableInventory,
|
||||
equipment,
|
||||
gearRecovery: mergeGearRecovery(existingRecovery, { items: recoveredItems, equipment: recoveredEquipment }),
|
||||
};
|
||||
}
|
||||
|
||||
function isCharacterProfile(value: unknown): value is CharacterProfile {
|
||||
@@ -147,6 +348,7 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
level?: unknown;
|
||||
inventory?: unknown;
|
||||
equipment?: unknown;
|
||||
gearRecovery?: unknown;
|
||||
manastormProgress?: unknown;
|
||||
settings?: unknown;
|
||||
secondaryClassId?: unknown;
|
||||
@@ -196,14 +398,19 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
})) as Record<string, Record<string, string | null>>;
|
||||
const activeLoadoutKey = orderedClassLoadoutKey(value.classId, secondaryClassId);
|
||||
actionLoadouts[activeLoadoutKey] = { ...(actionLoadouts[activeLoadoutKey] ?? actionBindings) };
|
||||
const inventory = normalizeInventory(legacy.inventory);
|
||||
const equipment = normalizeEquipment(legacy.equipment, inventory);
|
||||
const normalizedGear = normalizeProfileGear(
|
||||
legacy.inventory,
|
||||
legacy.equipment,
|
||||
categoryId,
|
||||
legacy.gearRecovery,
|
||||
);
|
||||
const { inventory, equipment, gearRecovery } = normalizedGear;
|
||||
const manastormProgress = normalizeManastormProgress(legacy.manastormProgress);
|
||||
const settings = legacy.settings && typeof legacy.settings === "object" && !Array.isArray(legacy.settings)
|
||||
? { ...(legacy.settings as Partial<GameplaySettings>) }
|
||||
: undefined;
|
||||
const dungeonRunCheckpoint = normalizeDungeonRunCheckpoint(legacy.dungeonRunCheckpoint);
|
||||
return {
|
||||
const normalizedProfile: CharacterProfile = {
|
||||
...profile,
|
||||
level,
|
||||
experience,
|
||||
@@ -220,9 +427,15 @@ function normalizeCharacterProfile(value: CharacterProfile): CharacterProfile {
|
||||
dungeonRunCheckpoint,
|
||||
settings,
|
||||
};
|
||||
if (gearRecovery) normalizedProfile.gearRecovery = gearRecovery;
|
||||
else delete normalizedProfile.gearRecovery;
|
||||
return normalizedProfile;
|
||||
}
|
||||
|
||||
function readDatabase(storage: StorageLike | null = browserLocalStorage()): ProfileDatabase {
|
||||
function readDatabase(
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
persistMigrations = true,
|
||||
): ProfileDatabase {
|
||||
if (!storage) return emptyDatabase();
|
||||
try {
|
||||
const parsed = JSON.parse(storage.getItem(DATABASE_KEY) ?? "null") as ({
|
||||
@@ -233,7 +446,7 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
presentationSettingsByOwner?: unknown;
|
||||
uiPreferencesByOwner?: unknown;
|
||||
}) | null;
|
||||
if (!parsed || ![1, 2, 3, 4, 5, 6, 7].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
if (!parsed || ![1, 2, 3, 4, 5, 6, 7, 8].includes(parsed.version ?? 0) || !Array.isArray(parsed.accounts) || !parsed.rosters || typeof parsed.rosters !== "object") {
|
||||
return emptyDatabase();
|
||||
}
|
||||
const accounts = parsed.accounts.filter((account): account is LocalAccount => Boolean(
|
||||
@@ -296,8 +509,8 @@ function readDatabase(storage: StorageLike | null = browserLocalStorage()): Prof
|
||||
);
|
||||
const rawUi = parsed.uiPreferencesByOwner && typeof parsed.uiPreferencesByOwner === "object" ? parsed.uiPreferencesByOwner : {};
|
||||
const uiPreferencesByOwner = Object.fromEntries(Object.entries(rawUi).map(([owner, value]) => [owner, normalizeUiPreferences(value)]));
|
||||
const database: ProfileDatabase = { version: 7, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
|
||||
if (parsed.version !== 7 || presentationSettingsMigrated || cardsMigrated || statRulesetsMigrated) {
|
||||
const database: ProfileDatabase = { version: 8, accounts, rosters, bossCardsByOwner, presentationSettingsByOwner, uiPreferencesByOwner };
|
||||
if (persistMigrations && (parsed.version !== 8 || presentationSettingsMigrated || cardsMigrated || statRulesetsMigrated)) {
|
||||
// A full or read-only device must still expose its existing roster.
|
||||
// Retry migration on the next read when persistence is available.
|
||||
try { writeDatabase(database, storage); } catch { /* Keep the normalized in-memory profiles. */ }
|
||||
@@ -534,10 +747,20 @@ export function replaceCharactersForOwner(
|
||||
? mergeBossCardCollections(roster.map((character) => character.bossCards), "sum")
|
||||
: normalizeBossCardCollection(accountBossCards);
|
||||
database.bossCardsByOwner[ownerId] = mergeBossCardCollections([database.bossCardsByOwner[ownerId], importedCards]);
|
||||
database.rosters[ownerId] = roster.map((character) => normalizeCharacterProfile({
|
||||
...character,
|
||||
ownerId,
|
||||
}));
|
||||
const existingRecoveryByCharacterId = new Map(
|
||||
(database.rosters[ownerId] ?? []).map((character) => [character.id, character.gearRecovery]),
|
||||
);
|
||||
database.rosters[ownerId] = roster.map((character) => {
|
||||
const gearRecovery = mergeGearRecovery(
|
||||
existingRecoveryByCharacterId.get(character.id),
|
||||
character.gearRecovery,
|
||||
);
|
||||
return normalizeCharacterProfile({
|
||||
...character,
|
||||
ownerId,
|
||||
...(gearRecovery ? { gearRecovery } : {}),
|
||||
});
|
||||
});
|
||||
writeDatabase(database, storage);
|
||||
return listCharacters(ownerId, storage);
|
||||
}
|
||||
@@ -800,7 +1023,11 @@ export function updateCharacterInventory(
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): CharacterProfile | null {
|
||||
if (!storage) return null;
|
||||
const database = readDatabase(storage);
|
||||
// Do not persist a migration if this mutation is rejected. Existing profile
|
||||
// bytes (including legacy gear and its quarantine state) remain untouched.
|
||||
const database = readDatabase(storage, false);
|
||||
const current = (database.rosters[ownerId] ?? []).find((character) => character.id === characterId);
|
||||
if (!current?.categoryId || !incomingInventoryMatchesCategory(inventory, current.categoryId)) return null;
|
||||
let updated: CharacterProfile | null = null;
|
||||
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
||||
if (character.id !== characterId) return character;
|
||||
@@ -821,7 +1048,15 @@ export function updateCharacterEquipment(
|
||||
storage: StorageLike | null = browserLocalStorage(),
|
||||
): CharacterProfile | null {
|
||||
if (!storage) return null;
|
||||
const database = readDatabase(storage);
|
||||
// Equipment updates are all-or-nothing: never silently erase assignments to
|
||||
// foreign or quarantined inventory during normalization.
|
||||
const database = readDatabase(storage, false);
|
||||
const current = (database.rosters[ownerId] ?? []).find((character) => character.id === characterId);
|
||||
if (
|
||||
!current
|
||||
|| equipmentReferencesRecoveredItem(equipment, current.gearRecovery)
|
||||
|| !equipmentReferencesUsableInventory(equipment, current)
|
||||
) return null;
|
||||
let updated: CharacterProfile | null = null;
|
||||
database.rosters[ownerId] = (database.rosters[ownerId] ?? []).map((character) => {
|
||||
if (character.id !== characterId) return character;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PlayerSession } from "./types";
|
||||
import { EMPTY_ITEM_STATS } from "../game/itemStats";
|
||||
|
||||
function storage() {
|
||||
const values = new Map<string, string>();
|
||||
@@ -101,4 +102,65 @@ describe("account boss card integration", () => {
|
||||
expect(repository.loadAccountBossCards(session.ownerId).counts["wow:lady anacondra"]?.normal).toBe(10);
|
||||
await client.logoutOnlineSession(session);
|
||||
});
|
||||
|
||||
it("uploads cloud gear namespace quarantine once without changing card synchronization", async () => {
|
||||
const device = storage();
|
||||
vi.stubGlobal("window", { localStorage: device });
|
||||
const repository = await import("./accountRepository");
|
||||
const character = repository.createCharacter(draft).character!;
|
||||
const foreignItem = {
|
||||
contentCategory: "rom",
|
||||
instanceId: "foreign-rom-item",
|
||||
itemId: 10412,
|
||||
itemKey: "rom:foreign-item",
|
||||
name: "Foreign RoM item",
|
||||
quality: "rare",
|
||||
inventorySlot: "waist",
|
||||
armorType: "leather",
|
||||
weaponType: null,
|
||||
itemLevel: 17,
|
||||
sourceItemLevel: 21,
|
||||
stats: { ...EMPTY_ITEM_STATS, agility: 4 },
|
||||
quantity: 1,
|
||||
sourceBossKey: "rom:source-boss",
|
||||
sourceBossName: "Source Boss",
|
||||
acquiredAt: 5_000,
|
||||
};
|
||||
let cloudData: unknown = {
|
||||
version: 2,
|
||||
characters: [{
|
||||
...character,
|
||||
inventory: [foreignItem],
|
||||
equipment: { player: { waist: foreignItem.instanceId } },
|
||||
}],
|
||||
uiPreferences: {},
|
||||
bossCards: { counts: {}, lastClaims: {} },
|
||||
};
|
||||
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
|
||||
if (init.method === "PUT") cloudData = JSON.parse(String(init.body)).data;
|
||||
return new Response(JSON.stringify({ revision: 1, updatedAt: 1, data: cloudData }), { status: 200 });
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const client = await import("./onlineAccountClient");
|
||||
|
||||
const hydrated = await client.hydrateOnlineSession(session);
|
||||
expect(hydrated[0]?.inventory).toEqual([]);
|
||||
expect(hydrated[0]?.equipment).toEqual({});
|
||||
expect(hydrated[0]?.gearRecovery).toMatchObject({
|
||||
items: [{ item: foreignItem, reason: "foreign-category" }],
|
||||
equipment: [{ ownerId: "player", slot: "waist", instanceId: foreignItem.instanceId, reason: "foreign-category" }],
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(501);
|
||||
const firstPut = fetchMock.mock.calls.filter(([, init]) => init.method === "PUT");
|
||||
expect(firstPut).toHaveLength(1);
|
||||
expect(cloudData).toMatchObject({
|
||||
bossCards: { counts: {}, lastClaims: {} },
|
||||
characters: [{ inventory: [], gearRecovery: { items: [{ reason: "foreign-category" }] } }],
|
||||
});
|
||||
|
||||
await client.hydrateOnlineSession(session);
|
||||
await vi.advanceTimersByTimeAsync(501);
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init.method === "PUT")).toHaveLength(1);
|
||||
await client.logoutOnlineSession(session);
|
||||
});
|
||||
});
|
||||
@@ -150,6 +150,32 @@ export function retryCloudSave(): void {
|
||||
if (activeSession) queueCloudSave(activeSession.ownerId, listCharacters(activeSession.ownerId));
|
||||
}
|
||||
|
||||
function gearNamespaceSnapshot(value: unknown): string {
|
||||
if (!Array.isArray(value)) return "[]";
|
||||
return JSON.stringify(value.map((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return candidate;
|
||||
const character = candidate as Record<string, unknown>;
|
||||
const rawInventory = character.inventory;
|
||||
const inventory = Array.isArray(rawInventory)
|
||||
? rawInventory.map((rawItem) => {
|
||||
if (!rawItem || typeof rawItem !== "object" || Array.isArray(rawItem)) return rawItem;
|
||||
const item = rawItem as Record<string, unknown>;
|
||||
return {
|
||||
instanceId: item.instanceId,
|
||||
contentCategory: Object.prototype.hasOwnProperty.call(item, "contentCategory")
|
||||
? item.contentCategory
|
||||
: "[missing-content-category]",
|
||||
};
|
||||
})
|
||||
: rawInventory == null ? [] : rawInventory;
|
||||
return {
|
||||
id: character.id,
|
||||
inventory,
|
||||
gearRecovery: character.gearRecovery ?? null,
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
export async function hydrateOnlineSession(session: PlayerSession): Promise<CharacterProfile[]> {
|
||||
if (!session.accessToken) return listCharacters(session.ownerId);
|
||||
activateOnlineSession(session);
|
||||
@@ -163,7 +189,12 @@ export async function hydrateOnlineSession(session: PlayerSession): Promise<Char
|
||||
suppressUpload = false;
|
||||
}
|
||||
// Publish migrated logs even if the player has not earned another card yet.
|
||||
if (JSON.stringify(cloud.data?.bossCards) !== JSON.stringify(loadAccountBossCards(session.ownerId))) {
|
||||
const gearNamespaceMigrated = gearNamespaceSnapshot(cloud.data?.characters)
|
||||
!== gearNamespaceSnapshot(characters);
|
||||
if (
|
||||
gearNamespaceMigrated
|
||||
|| JSON.stringify(cloud.data?.bossCards) !== JSON.stringify(loadAccountBossCards(session.ownerId))
|
||||
) {
|
||||
queueCloudSave(session.ownerId, characters);
|
||||
}
|
||||
return characters;
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("RoM dual-class persistence", () => {
|
||||
"priest:none": { "primary:face-bottom": "priest-flash-heal" },
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(7);
|
||||
expect(JSON.parse(storage.getItem(PROFILE_STORAGE_KEYS.database)!).version).toBe(8);
|
||||
});
|
||||
|
||||
it("locks selection to level ten and preserves separate bars through atomic swaps", () => {
|
||||
|
||||
@@ -22,6 +22,28 @@ export interface PlayerSession {
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
export type GearRecoveryReason = "missing-category" | "foreign-category" | "invalid-native-payload";
|
||||
|
||||
/** Original rejected gear data kept with its character so migrations never erase it. */
|
||||
export interface GearRecoveryItem {
|
||||
readonly item: unknown;
|
||||
readonly reason: GearRecoveryReason;
|
||||
}
|
||||
|
||||
/** A former loadout reference for an item that is quarantined from active gear. */
|
||||
export interface GearRecoveryEquipmentReference {
|
||||
readonly ownerId: string;
|
||||
readonly slot: string;
|
||||
readonly instanceId: string;
|
||||
readonly reason: GearRecoveryReason;
|
||||
}
|
||||
|
||||
/** Character-scoped recovery for legacy or foreign-namespace gear. */
|
||||
export interface CharacterGearRecovery {
|
||||
readonly items: readonly GearRecoveryItem[];
|
||||
readonly equipment: readonly GearRecoveryEquipmentReference[];
|
||||
}
|
||||
|
||||
export interface CharacterProfile {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
@@ -50,6 +72,8 @@ export interface CharacterProfile {
|
||||
actionLoadouts?: Record<string, Record<string, string | null>>;
|
||||
inventory: InventoryItem[];
|
||||
equipment: EquipmentAssignments;
|
||||
/** Rejected gear remains recoverable here and is excluded from active inventory/loadouts. */
|
||||
gearRecovery?: CharacterGearRecovery;
|
||||
/** Legacy character log, read only during migration to the account collection. */
|
||||
bossCards?: BossCardCollection;
|
||||
/** Present on normalized and newly created profiles; optional for legacy callers. */
|
||||
|
||||
@@ -196,6 +196,7 @@ export type AbilityEffect =
|
||||
| ({ readonly kind: "dot"; readonly coefficient: number; readonly school?: DamageSchool; readonly ticks: number; readonly intervalMs: number; readonly startsImmediately?: boolean; readonly tag?: string } & AbilityAmountScaling & AbilityDamageResource)
|
||||
| ({ readonly kind: "hot"; readonly coefficient: number; readonly ticks: number; readonly intervalMs: number; readonly percentMaxHealth?: number } & AbilityAmountScaling & AbilityHealingRecipients)
|
||||
| { readonly kind: "resurrection"; readonly percentMaxHealth: number; readonly flatHealth?: number }
|
||||
| { readonly kind: "sacrifice-minion"; readonly creatureIds: readonly number[]; readonly radius: number; readonly percentMaxHealth: number }
|
||||
| {
|
||||
readonly kind: "summon";
|
||||
readonly creatureId: number;
|
||||
|
||||
@@ -96,7 +96,7 @@ export function aiHintsForAbility(ability: AbilityDefinition): AbilityAiHints {
|
||||
for (const effect of ability.effects) {
|
||||
if (["damage", "dot", "finisher-damage"].includes(effect.kind)) categories.add("damage");
|
||||
if (effect.kind === "execute") categories.add("execute");
|
||||
if (effect.kind === "heal" || effect.kind === "hot") categories.add("heal");
|
||||
if (effect.kind === "heal" || effect.kind === "hot" || effect.kind === "sacrifice-minion") categories.add("heal");
|
||||
if (effect.kind === "shield") categories.add("mitigation");
|
||||
if (effect.kind === "taunt") categories.add("taunt");
|
||||
if (effect.kind === "interrupt") categories.add("interrupt");
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilityAtLevel, abilitiesForClass } from "../abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "../aggro";
|
||||
import { useCombatStore } from "../combatStore";
|
||||
import { clearMobRuntimeRegistry } from "../mobRuntimeRegistry";
|
||||
import { clearPartyRuntimeRegistry } from "../partyRuntimeRegistry";
|
||||
import { BenchmarkController } from "./benchmarkController";
|
||||
import { BENCHMARK_SCENARIOS } from "./benchmarkScenarios";
|
||||
|
||||
const scenario = { ...BENCHMARK_SCENARIOS[0], hostileTargets: 1 };
|
||||
|
||||
describe("benchmark maintenance aura fallback", () => {
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
clearPartyRuntimeRegistry();
|
||||
});
|
||||
|
||||
it("does not re-admit an active self buff after shared rotation rejects it", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "paladin", role: "tank", level: 60 });
|
||||
const sanctuary = abilityAtLevel(
|
||||
abilitiesForClass("paladin").find((ability) => ability.name === "Greater Blessing of Sanctuary")!,
|
||||
60,
|
||||
);
|
||||
useCombatStore.setState({ abilities: [sanctuary] });
|
||||
expect(useCombatStore.getState().castAbility(sanctuary.id, { now: 100 }).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.id === sanctuary.effects.find((effect) => effect.kind === "apply-aura")?.aura.id)).toBe(true);
|
||||
|
||||
const decision = new BenchmarkController().act(2_000, "tank", scenario, false);
|
||||
|
||||
expect(decision.cast).toBe(false);
|
||||
expect(useCombatStore.getState().auras.filter((aura) => aura.definition.id
|
||||
=== sanctuary.effects.find((effect) => effect.kind === "apply-aura")?.aura.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("allows the same maintenance cast after its aura expires or is removed", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "paladin", role: "tank", level: 60 });
|
||||
const sanctuary = abilityAtLevel(
|
||||
abilitiesForClass("paladin").find((ability) => ability.name === "Greater Blessing of Sanctuary")!,
|
||||
60,
|
||||
);
|
||||
const auraId = sanctuary.effects.find((effect) => effect.kind === "apply-aura")!.aura.id;
|
||||
useCombatStore.setState({ abilities: [sanctuary] });
|
||||
expect(useCombatStore.getState().castAbility(sanctuary.id, { now: 100 }).ok).toBe(true);
|
||||
useCombatStore.setState((state) => ({
|
||||
auras: state.auras.map((aura) => aura.definition.id === auraId ? { ...aura, expiresAt: 1_999 } : aura),
|
||||
}));
|
||||
|
||||
expect(new BenchmarkController().act(2_000, "tank", scenario, false).cast).toBe(true);
|
||||
|
||||
useCombatStore.setState({ auras: [] });
|
||||
expect(new BenchmarkController().act(4_000, "tank", scenario, false).cast).toBe(true);
|
||||
});
|
||||
|
||||
it("does not re-admit an active RoM self debuff through generic candidate scoring", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rom-scout", statRuleset: "rom", role: "damage", level: 30 });
|
||||
const detection = abilityAtLevel(
|
||||
abilitiesForClass("rom-scout").find((ability) => ability.id === "rom-scout-primary-8")!,
|
||||
30,
|
||||
);
|
||||
useCombatStore.setState({ abilities: [detection] });
|
||||
expect(useCombatStore.getState().castAbility(detection.id, { now: 100 }).ok).toBe(true);
|
||||
expect(useCombatStore.getState().auras.some((aura) => aura.targetId === PLAYER_AGGRO_ID
|
||||
&& aura.definition.id === detection.effects.find((effect) => effect.kind === "apply-aura")?.aura.id)).toBe(true);
|
||||
|
||||
const decision = new BenchmarkController().act(2_000, "damage", scenario, false);
|
||||
|
||||
expect(decision.cast).toBe(false);
|
||||
expect(useCombatStore.getState().auras.filter((aura) => aura.definition.id
|
||||
=== detection.effects.find((effect) => effect.kind === "apply-aura")?.aura.id)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import type { BenchmarkScenarioDefinition } from "./benchmarkTypes";
|
||||
import { nativeRotationAbilityAllowed, nativeRotationProfileFor } from "../romFiveRotation";
|
||||
import {
|
||||
COMBAT_ROTATION_VERSION,
|
||||
abilityAuraMaintainedOnTarget,
|
||||
evaluateCombatRotation,
|
||||
nativeEarlyPriestHealReserveBlocksAbility,
|
||||
type RotationContext,
|
||||
@@ -52,6 +53,15 @@ interface ScoredAbility {
|
||||
readonly friendlyTarget: FriendlyTarget | null;
|
||||
}
|
||||
|
||||
function scoredAbilityAuraTargetId(candidate: ScoredAbility, combat: CombatState): string | null {
|
||||
const { ability, friendlyTarget } = candidate;
|
||||
if (ability.target === "friendly" || (ability.target === "any" && friendlyTarget)) {
|
||||
return friendlyTarget?.id ?? combat.selectedFriendlyActorId ?? PLAYER_AGGRO_ID;
|
||||
}
|
||||
if (ability.target === "hostile" || ability.target === "any") return combat.selectedTargetId;
|
||||
return PLAYER_AGGRO_ID;
|
||||
}
|
||||
|
||||
const TRANSIENT_FAILURES = new Set<CastFailureReason>([
|
||||
"already-casting",
|
||||
"global-cooldown",
|
||||
@@ -287,13 +297,10 @@ export class BenchmarkController {
|
||||
maintainedAbilityIds: new Set([
|
||||
...combat.effects.filter((effect) => effect.sourceActorId === PLAYER_AGGRO_ID && effect.remainingTicks > 0)
|
||||
.map((effect) => effect.sourceAbilityId),
|
||||
...combat.auras.filter((aura) => aura.sourceId === PLAYER_AGGRO_ID
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now))
|
||||
.flatMap((aura) => availableAbilities
|
||||
.filter((ability) => ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& effect.aura.id === aura.definition.id))
|
||||
.map((ability) => ability.id)),
|
||||
]),
|
||||
maintainedAuras: combat.auras
|
||||
.filter((aura) => aura.expiresAt === null || aura.expiresAt > now)
|
||||
.map((aura) => ({ auraId: aura.definition.id, targetId: aura.targetId })),
|
||||
maintainedPeriodicEffects: combat.effects.map((effect) => ({
|
||||
abilityId: effect.sourceAbilityId,
|
||||
sourceActorId: effect.sourceActorId,
|
||||
@@ -315,9 +322,11 @@ export class BenchmarkController {
|
||||
};
|
||||
const sharedDecision = evaluateCombatRotation(rotationContext);
|
||||
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
||||
const ability = candidates[index].ability;
|
||||
const candidate = candidates[index];
|
||||
const ability = candidate.ability;
|
||||
if (nativeEarlyPriestHealReserveBlocksAbility(ability, rotationContext)
|
||||
|| (nativeRotationProfileFor(rotationContext) && !nativeRotationAbilityAllowed(ability, rotationContext))) {
|
||||
|| (nativeRotationProfileFor(rotationContext) && !nativeRotationAbilityAllowed(ability, rotationContext))
|
||||
|| abilityAuraMaintainedOnTarget(ability, rotationContext, scoredAbilityAuraTargetId(candidate, combat))) {
|
||||
candidates.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface StructuredCombatEvent {
|
||||
readonly sourceActorId: string;
|
||||
readonly targetActorId: string;
|
||||
readonly abilityId?: string;
|
||||
/** Source ability that caused an interruption; abilityId remains the canceled cast. */
|
||||
readonly causeAbilityId?: string;
|
||||
readonly school?: DamageSchool;
|
||||
readonly rawAmount: number;
|
||||
readonly effectiveAmount: number;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import {
|
||||
abilityIsPrecombatPreparation,
|
||||
abilityHasRotationSemantics,
|
||||
abilityAuraMaintainedOnTarget,
|
||||
CLASSIC_ROTATION_PROFILES,
|
||||
COMBAT_ROTATION_VERSION,
|
||||
evaluateCombatRotation,
|
||||
@@ -764,6 +765,52 @@ describe("shared combat rotations", () => {
|
||||
.toBe(lightningBolt.id);
|
||||
});
|
||||
|
||||
it("matches maintained auras by recipient and exact effect while allowing a group override", () => {
|
||||
const auraAbility = (id: string, auraId: string, recipient: "caster" | "ability-target" = "caster") => semanticAbility(id, [{
|
||||
kind: "apply-aura",
|
||||
aura: {
|
||||
id: auraId,
|
||||
name: id,
|
||||
disposition: "buff",
|
||||
durationMs: 300_000,
|
||||
exclusiveGroup: "test:stance",
|
||||
modifiers: [{ kind: "stat", stat: "armor", operation: "flat", value: 10 }],
|
||||
},
|
||||
recipient,
|
||||
sourceEffectType: 6,
|
||||
sourceAuraType: 22,
|
||||
miscValue: 0,
|
||||
triggerSpellId: 0,
|
||||
}], { target: "self" });
|
||||
const stance = auraAbility("stance", "stance-aura");
|
||||
const replacementStance = auraAbility("replacement-stance", "replacement-aura");
|
||||
const allyAura = {
|
||||
...auraAbility("ally-aura", "ally-aura-id", "ability-target"),
|
||||
target: "friendly" as const,
|
||||
};
|
||||
const activeOnSelf = context({
|
||||
selfId: "player",
|
||||
maintainedAuras: [{ auraId: "stance-aura", targetId: "player" }],
|
||||
});
|
||||
|
||||
expect(abilityAuraMaintainedOnTarget(stance, activeOnSelf, "player")).toBe(true);
|
||||
expect(abilityAuraMaintainedOnTarget(allyAura, activeOnSelf, "ally")).toBe(false);
|
||||
expect(abilityAuraMaintainedOnTarget(stance, {
|
||||
...activeOnSelf,
|
||||
maintainedAuras: [{ auraId: "stance-aura", targetId: "ally" }],
|
||||
}, "player")).toBe(false);
|
||||
expect(abilityAuraMaintainedOnTarget(allyAura, {
|
||||
...activeOnSelf,
|
||||
maintainedAuras: [{ auraId: "ally-aura-id", targetId: "player" }],
|
||||
}, "player")).toBe(true);
|
||||
expect(abilityAuraMaintainedOnTarget(replacementStance, activeOnSelf, "player")).toBe(false);
|
||||
expect(abilityAuraMaintainedOnTarget({
|
||||
...stance,
|
||||
effects: [...stance.effects, { kind: "damage", coefficient: 1 }],
|
||||
}, activeOnSelf, "player")).toBe(false);
|
||||
expect(abilityAuraMaintainedOnTarget(stance, { ...activeOnSelf, maintainedAuras: [] }, "player")).toBe(false);
|
||||
});
|
||||
|
||||
it("maintains Earth Shield on a valid full-health ally", () => {
|
||||
const earthShield = ability("shaman", "Earth Shield");
|
||||
const shaman = context({
|
||||
|
||||
@@ -94,6 +94,11 @@ export interface RotationContext {
|
||||
readonly targetId: string;
|
||||
readonly remainingMs: number;
|
||||
}[];
|
||||
/** Active applied auras with their actual recipient, filtered for expiry. */
|
||||
readonly maintainedAuras?: readonly {
|
||||
readonly auraId: string;
|
||||
readonly targetId: string;
|
||||
}[];
|
||||
readonly activeAuraExclusiveGroups?: ReadonlySet<string>;
|
||||
readonly activeSummonAbilityIds?: ReadonlySet<string>;
|
||||
readonly hasActivePet?: boolean;
|
||||
@@ -378,6 +383,36 @@ function targetForAbility(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether this applied-aura action is already active on the recipient it would use. */
|
||||
export function abilityAuraMaintainedOnTarget(
|
||||
ability: AbilityDefinition,
|
||||
context: RotationContext,
|
||||
targetId: string | null,
|
||||
): boolean {
|
||||
// An aura can be a side effect of an attack. Keep those casts available for
|
||||
// their damage or healing; this guard only handles maintenance actions.
|
||||
if (ability.effects.some((effect) => effect.kind === "damage" || effect.kind === "dot"
|
||||
|| effect.kind === "heal" || effect.kind === "hot" || effect.kind === "shield"
|
||||
|| effect.kind === "finisher-damage" || effect.kind === "execute")) return false;
|
||||
const auraEffects = ability.effects.filter((effect) => effect.kind === "apply-aura");
|
||||
if (auraEffects.length === 0) return false;
|
||||
const maintainedAuras = context.maintainedAuras;
|
||||
if (maintainedAuras !== undefined) {
|
||||
return auraEffects.some((effect) => {
|
||||
const recipientId = effect.recipient === "caster"
|
||||
? context.selfId ?? null
|
||||
: effect.recipient === "pet" ? null : targetId;
|
||||
return recipientId !== null && maintainedAuras.some((aura) => (
|
||||
aura.targetId === recipientId && aura.auraId === effect.aura.id
|
||||
));
|
||||
});
|
||||
}
|
||||
// Older selector callers only expose caster-owned exclusive groups. Keep
|
||||
// their conservative behavior until they provide recipient-scoped auras.
|
||||
return auraEffects.some((effect) => Boolean(effect.aura.exclusiveGroup
|
||||
&& context.activeAuraExclusiveGroups?.has(effect.aura.exclusiveGroup)));
|
||||
}
|
||||
|
||||
interface ScoredRotationAbility {
|
||||
readonly ability: AbilityDefinition;
|
||||
readonly score: number;
|
||||
@@ -410,13 +445,20 @@ function scoreAbility(
|
||||
&& ability.effects.some((effect) => effect.kind === "dot" || effect.kind === "hot");
|
||||
const coaPeriodic = context.maintainedPeriodicEffects !== undefined && Boolean(context.selfId)
|
||||
&& usesCoaTargetedPeriodicMaintenance(ability, context);
|
||||
const maintenanceTargetId = targetForAbility(ability, context, lowest)
|
||||
?? (ability.target === "self" ? context.selfId ?? null : null);
|
||||
const periodicMaintained = coaPeriodic
|
||||
? coaPeriodicActionMaintainedOnTarget(ability, context, targetForAbility(ability, context, lowest))
|
||||
: !nativePeriodic && (context.maintainedAbilityIds?.has(ability.id) ?? false);
|
||||
? coaPeriodicActionMaintainedOnTarget(ability, context, maintenanceTargetId)
|
||||
: !nativePeriodic && (context.maintainedPeriodicEffects !== undefined
|
||||
? context.maintainedPeriodicEffects.some((effect) => (
|
||||
effect.abilityId === ability.id
|
||||
&& effect.sourceActorId === context.selfId
|
||||
&& effect.targetId === maintenanceTargetId
|
||||
&& effect.remainingMs > 0
|
||||
))
|
||||
: (context.maintainedAbilityIds?.has(ability.id) ?? false));
|
||||
const maintained = periodicMaintained
|
||||
|| ability.effects.some((effect) => effect.kind === "apply-aura"
|
||||
&& Boolean(effect.aura.exclusiveGroup
|
||||
&& context.activeAuraExclusiveGroups?.has(effect.aura.exclusiveGroup)));
|
||||
|| abilityAuraMaintainedOnTarget(ability, context, maintenanceTargetId);
|
||||
const activeSummon = context.activeSummonAbilityIds?.has(ability.id) ?? false;
|
||||
const petSummon = isPetSummon(ability);
|
||||
const offensiveSummon = isOffensiveSummon(ability);
|
||||
@@ -679,6 +721,12 @@ function scoreAbility(
|
||||
score += injured.length ? -3_000 : 300;
|
||||
}
|
||||
} else {
|
||||
if (ability.effects.some((effect) => effect.kind === "sacrifice-minion")) {
|
||||
// Automated minion sacrifices are emergency self-heals; the cast gate
|
||||
// still requires a living owned source minion before resources are spent.
|
||||
if (selfHealth >= cooldownPolicy.generic.defensiveHealthPercent / 100) return null;
|
||||
score += 140_000;
|
||||
}
|
||||
if (categories.has("mitigation")) {
|
||||
const defensiveThreshold = cooldownPolicy.generic.defensiveHealthPercent / 100;
|
||||
const emergencyThreshold = cooldownPolicy.generic.emergencyHealthPercent / 100;
|
||||
|
||||
@@ -30,6 +30,8 @@ import { subscribeCombatPresentation, type CombatPresentationEvent } from "./com
|
||||
import { loadCanonicalBenchmarkBuilds } from "./benchmark/benchmarkBuilds";
|
||||
import { runtimePartyTalentRanksForLevel } from "./partyTalentRuntime";
|
||||
import { talentModifiersForRanks } from "./talentModifiers";
|
||||
import type { InventoryItem } from "./lootTypes";
|
||||
import { resolvedEquipmentStats } from "./heirloomEquipment";
|
||||
import {
|
||||
ROGUE_DEADLY_POISON_DOSE_TAG,
|
||||
ROGUE_ENVENOM_BUFF_TAG,
|
||||
@@ -63,6 +65,31 @@ function stableUnit(seed: string): number {
|
||||
return (hash >>> 0) / 0x1_0000_0000;
|
||||
}
|
||||
|
||||
function categoryGearFixture(
|
||||
instanceId: string,
|
||||
contentCategory: InventoryItem["contentCategory"],
|
||||
romNative?: InventoryItem["stats"]["romNative"],
|
||||
): InventoryItem {
|
||||
return {
|
||||
contentCategory,
|
||||
instanceId,
|
||||
itemId: 900001,
|
||||
itemKey: instanceId,
|
||||
name: instanceId,
|
||||
quality: "rare",
|
||||
inventorySlot: "back",
|
||||
armorType: null,
|
||||
weaponType: null,
|
||||
itemLevel: 40,
|
||||
sourceItemLevel: 40,
|
||||
stats: { ...EMPTY_ITEM_STATS, stamina: 100, ...(romNative ? { romNative } : {}) },
|
||||
quantity: 1,
|
||||
sourceBossKey: "test-source",
|
||||
sourceBossName: "Test Source",
|
||||
acquiredAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function paladinVindicationProcTime(
|
||||
sourceActorId: string,
|
||||
abilityId: string,
|
||||
@@ -1215,6 +1242,52 @@ describe("combat store", () => {
|
||||
expect(useCombatStore.getState().bossCards.counts["wow:lady anacondra"]?.normal).toBe(1);
|
||||
});
|
||||
|
||||
it("derives the owner namespace from the source class and accepts only validated native gear rewards", () => {
|
||||
const foreignWow = categoryGearFixture("foreign-wow", "wow");
|
||||
const missingNative = categoryGearFixture("rom-without-native", "rom");
|
||||
const missingCategory = categoryGearFixture("legacy-without-category", undefined);
|
||||
const nullCategory = categoryGearFixture("explicit-null-category", null);
|
||||
const nativeRom = categoryGearFixture("rom-native", "rom", { stamina: 4 });
|
||||
const sourceProfile = {
|
||||
classId: "rom-priest",
|
||||
categoryId: "wow",
|
||||
level: 40,
|
||||
inventory: [foreignWow, missingNative, missingCategory, nullCategory, nativeRom],
|
||||
equipment: { player: { back: foreignWow.instanceId, chest: missingNative.instanceId } },
|
||||
} as const;
|
||||
useCombatStore.getState().initializeCharacter(sourceProfile);
|
||||
|
||||
expect(useCombatStore.getState().contentCategoryId).toBe("rom");
|
||||
expect(useCombatStore.getState().inventory.map((item) => item.instanceId)).toEqual([nativeRom.instanceId]);
|
||||
expect(useCombatStore.getState().equipment).toEqual({});
|
||||
expect(sourceProfile.inventory.map((item) => item.contentCategory)).toEqual(["wow", "rom", undefined, null, "rom"]);
|
||||
const expectedTrainingOnlyStats = resolvedEquipmentStats({}, [], {
|
||||
ownerId: "player",
|
||||
ownerKind: "player",
|
||||
classId: "rom-priest",
|
||||
secondaryClassId: null,
|
||||
specializationId: null,
|
||||
role: "healer",
|
||||
level: 40,
|
||||
});
|
||||
expect(useCombatStore.getState().gearStats).toEqual(expectedTrainingOnlyStats);
|
||||
|
||||
useCombatStore.getState().registerMob("gear-boundary", { name: "Gear Boundary", maxHealth: 1, hasLoot: true });
|
||||
const nativeLoot = categoryGearFixture("rom-loot", "rom", { agility: 2 });
|
||||
useCombatStore.setState((state) => {
|
||||
const mob = state.mobs["gear-boundary"]!;
|
||||
return { mobs: { ...state.mobs, "gear-boundary": {
|
||||
...mob,
|
||||
dead: true,
|
||||
health: 0,
|
||||
pendingLoot: [foreignWow, missingNative, missingCategory, nullCategory, nativeLoot],
|
||||
} } };
|
||||
});
|
||||
expect(useCombatStore.getState().lootMob("gear-boundary", 10_000)).toBe(true);
|
||||
expect(useCombatStore.getState().inventory.map((item) => item.instanceId))
|
||||
.toEqual([nativeRom.instanceId, nativeLoot.instanceId]);
|
||||
});
|
||||
|
||||
it("aggros every surviving packmate even when the pulled mob dies to the opening hit", () => {
|
||||
for (const [id, maxHealth] of [["one-shot:a", 1], ["one-shot:b", 100], ["outsider", 100]] as const) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
|
||||
@@ -1463,7 +1536,7 @@ describe("combat store", () => {
|
||||
registerTarget("pact-boss", [2, 0, 0], 1_000_000, 0);
|
||||
useCombatStore.getState().registerMob("pact-boss", { name: "Pact boss", boss: true, maxHealth: 1_000_000, xpReward: 0 });
|
||||
useCombatStore.setState({ resource: 100, resourcePools: { "runic-power": 100 } });
|
||||
expect(useCombatStore.getState().castAbility("wow335-death-knight-death-pact", undefined, 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().castAbility("wow335-death-knight-death-pact", undefined, 1_000)).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
expect(useCombatStore.getState().mobs["pact-boss"]).toMatchObject({ dead: false, health: 1_000_000 });
|
||||
});
|
||||
|
||||
|
||||
+201
-27
@@ -1,3 +1,4 @@
|
||||
import { eligibleSacrificeMinion } from "./sacrificeMinion";
|
||||
import { healerManaRecoveryAmount, synchronizeHealerManaRecovery } from "./healerManaRecovery";
|
||||
import { nativeDamageCasterAmount, nativeHolyHealCasterAmount, nativeHolyHealTargetAmount, nativePeriodicHealSnapshot, nativePeriodicDamageCasterSnapshot, nativeTrainingAutoAttackProfile, nativeDisarmedMainHandAutoAttackProfile, matchesNativeTrainingGear, type RomNativeDamageTargetContext } from "./romNativeCombatAmount";
|
||||
import { romNativeNpcCombatant, romNativeTrainingCombatant, romNativePlayerHitProfile, romNativeNpcHitProfile, resolveRomNativeCombatResult,
|
||||
@@ -64,11 +65,13 @@ import { useDungeonSimulationStore } from "./dungeonSimulationStore";
|
||||
import { useGameStore } from "./store";
|
||||
import {
|
||||
baseClassFor,
|
||||
contentCategoryForClass,
|
||||
isRomClassId,
|
||||
type ClassId,
|
||||
type GenderId,
|
||||
type RaceId,
|
||||
} from "../app/characterCatalog";
|
||||
import type { ContentCategoryId } from "../app/contentCategories";
|
||||
import { normalizeCharacterStatRuleset, type CharacterStatRuleset } from "./characterStatRuleset";
|
||||
import { applyRomNativeStatModifiers, type RomNativeAdditiveChannel, type RomNativeAttributeKey,
|
||||
type RomNativeDerivedStats, type RomNativeRateChannel, type RomNativeStatModifiers } from "./romNativeStats";
|
||||
@@ -455,10 +458,11 @@ import { bossCardForSource } from "./bossCardCatalog";
|
||||
import { collectBossCardDrop, emptyBossCardCollection, normalizeBossCardCollection, rollBossCards, type BossCardCollection, type BossCardDrop } from "./bossCards";
|
||||
import { adaptRunewakerEnemyDamage } from "./runewakerCombatFormula";
|
||||
import { channelAmountEffects, channelPulseAt, channelPulseCount } from "./combatChannelAmounts";
|
||||
import { normalizeInventory, type InventoryItem } from "./lootTypes";
|
||||
import { inventoryItemUsableForCategory, normalizeInventory, type InventoryItem } from "./lootTypes";
|
||||
import {
|
||||
PLAYER_EQUIPMENT_OWNER_ID,
|
||||
activeEquipmentStatsForClass,
|
||||
classCanEquipItem,
|
||||
equipInventoryItem,
|
||||
normalizeEquipment,
|
||||
unequipInventorySlot,
|
||||
@@ -1405,6 +1409,7 @@ export interface ActorHealingRequest {
|
||||
|
||||
export interface CharacterCombatSeed {
|
||||
readonly classId: ClassId;
|
||||
readonly categoryId?: ContentCategoryId;
|
||||
readonly statRuleset?: CharacterStatRuleset;
|
||||
readonly hunterPetFamily?: Wow335HunterPetFamily;
|
||||
readonly specializationId?: string | null;
|
||||
@@ -1570,6 +1575,7 @@ export interface EnemyPeriodicCombatEffect {
|
||||
export interface CombatState {
|
||||
classId: ClassId;
|
||||
statRuleset: CharacterStatRuleset;
|
||||
contentCategoryId: ContentCategoryId;
|
||||
readonly romNativeRecoveryClock?: RomNativeRecoveryClock;
|
||||
hunterPetFamily: Wow335HunterPetFamily;
|
||||
specializationId: string | null;
|
||||
@@ -1708,6 +1714,7 @@ export interface CombatState {
|
||||
controlMob: (id: string, sourceAbilityId: string, status: MobStatusKind, durationMs: number, now?: number, breakOnDamage?: boolean, sourceActorId?: string, magnitude?: number) => boolean;
|
||||
dispelActor: (id: string, categories: readonly DispelCategory[], maxCount?: number, now?: number) => number;
|
||||
healActor: (id: string, amount: number, request: ActorHealingRequest) => HealingResolution;
|
||||
sacrificeActorMinion: (ownerActorId: string, abilityId: string, effect: Extract<AbilityEffect, { kind: "sacrifice-minion" }>, origin: CombatPosition, now?: number) => boolean;
|
||||
/** Applies the CoA Holy Candle self-damage trigger to a companion or channel caster. */
|
||||
triggerRomPriestHolyCandle: (ownerActorId: string, healingAbilityId: string, now?: number) => number;
|
||||
reviveActor: (id: string, percentMaxHealth: number, sourceActorId: string, abilityId?: string, now?: number) => number;
|
||||
@@ -1818,6 +1825,30 @@ let summonSequence = 0;
|
||||
let areaEffectSequence = 0;
|
||||
let combatEventSequence = 0;
|
||||
|
||||
function sacrificeMinionInWork(
|
||||
work: Pick<MutableCastWork, "summons" | "actors" | "auras" | "effects" | "combatEvents">,
|
||||
ownerActorId: string,
|
||||
abilityId: string,
|
||||
effect: Extract<AbilityEffect, { kind: "sacrifice-minion" }>,
|
||||
origin: CombatPosition,
|
||||
now: number,
|
||||
): boolean {
|
||||
const minion = eligibleSacrificeMinion(work.summons, ownerActorId, effect, origin, now);
|
||||
if (!minion) return false;
|
||||
work.summons = work.summons.map((summon) => summon.id === minion.id
|
||||
? { ...summon, health: 0, targetId: null, cast: null } : summon);
|
||||
const actor = work.actors[minion.id];
|
||||
if (actor) work.actors[minion.id] = { ...actor, health: 0, alive: false, cast: null };
|
||||
work.auras = work.auras.filter((aura) => aura.targetId !== minion.id);
|
||||
work.effects = work.effects.filter((candidate) => candidate.targetId !== minion.id);
|
||||
recordCombatEventInWork(work, {
|
||||
occurredAt: now, kind: "death", sourceActorId: ownerActorId,
|
||||
targetActorId: minion.id, abilityId, rawAmount: 0, effectiveAmount: 0,
|
||||
overheal: 0, absorbed: 0, critical: false, outcome: "sacrificed",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function initialSummonCombatRuntime(): Pick<CombatSummon,
|
||||
"abilityIds" | "resources" | "cooldowns" | "globalCooldownEndsAt" | "cast" | "controlledUntil" | "schoolLockedUntil"
|
||||
> {
|
||||
@@ -1904,7 +1935,7 @@ function recordCombatEvent(
|
||||
return appendCombatEvent(events, input, combatEventSequence);
|
||||
}
|
||||
|
||||
function recordCombatEventInWork(work: MutableCastWork, input: CombatEventInput): void {
|
||||
function recordCombatEventInWork(work: Pick<MutableCastWork, "combatEvents">, input: CombatEventInput): void {
|
||||
work.combatEvents = [...recordCombatEvent(work.combatEvents, input)];
|
||||
}
|
||||
|
||||
@@ -1969,10 +2000,16 @@ function emitPlayerAbilityPresentation(
|
||||
if (penancePulse) emitCombatPresentation({ ...event, phase: "impact" });
|
||||
}
|
||||
|
||||
function emitPlayerAbilityCancellation(abilityId: string, occurredAt: number, origin: CombatPosition): void {
|
||||
function emitPlayerAbilityCancellation(
|
||||
abilityId: string,
|
||||
occurredAt: number,
|
||||
origin: CombatPosition,
|
||||
targetId: string | null = null,
|
||||
targetPosition: CombatPosition | null = null,
|
||||
): void {
|
||||
const ability = abilityById(abilityId);
|
||||
if (!ability) return;
|
||||
emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, null, origin);
|
||||
emitPlayerAbilityPresentation(ability, "cast-cancel", occurredAt, origin, targetId, targetPosition ?? origin);
|
||||
}
|
||||
|
||||
function emitPlayerAbilityResolutionPresentation(
|
||||
@@ -2380,6 +2417,33 @@ function playerEquippedMeleeWeapon(
|
||||
return item?.weaponType ? item.stats.meleeWeapon : null;
|
||||
}
|
||||
|
||||
function playerHasExplicitWoWWarriorDualWield(
|
||||
state: Pick<CombatState, "classId" | "level" | "statRuleset" | "equipment" | "inventory">,
|
||||
): boolean {
|
||||
if (state.classId !== "warrior" || state.level < 20
|
||||
|| normalizeCharacterStatRuleset(state.statRuleset, state.classId) !== "wow") return false;
|
||||
|
||||
const itemForSlot = (slot: "main-hand" | "off-hand"): InventoryItem | null => {
|
||||
const assignedId = state.equipment[PLAYER_EQUIPMENT_OWNER_ID]?.[slot];
|
||||
return assignedId ? state.inventory.find((item) => item.instanceId === assignedId) ?? null : null;
|
||||
};
|
||||
const mainHand = itemForSlot("main-hand");
|
||||
const offHand = itemForSlot("off-hand");
|
||||
const isClassLegalMeleeWeapon = (item: InventoryItem | null, allowedSlots: readonly string[]): boolean => Boolean(
|
||||
item
|
||||
&& allowedSlots.includes(item.inventorySlot)
|
||||
&& item.armorType !== "shield"
|
||||
&& item.weaponType
|
||||
&& item.stats.meleeWeapon
|
||||
&& classCanEquipItem(state.classId, item, { level: state.level })
|
||||
);
|
||||
|
||||
// WoW Warrior spell 674 is learned at level 20. Only actual, legal one-hand
|
||||
// assignments count; generated heirloom stats never grant a second swing.
|
||||
return isClassLegalMeleeWeapon(mainHand, ["one-hand", "main-hand"])
|
||||
&& isClassLegalMeleeWeapon(offHand, ["one-hand", "off-hand"]);
|
||||
}
|
||||
|
||||
function playerEquippedRangedWeapon(
|
||||
state: Pick<CombatState,
|
||||
"classId" | "secondaryClassId" | "specializationId" | "gearRole" | "level" | "equipment" | "inventory"
|
||||
@@ -7652,6 +7716,7 @@ function damageMobInWork(
|
||||
primarySaintsBlessingMultiplierAlreadyApplied = false,
|
||||
directPhysicalArmorAttackerLevel?: number,
|
||||
attackHand?: import("./combatEvents").StructuredCombatEvent["attackHand"],
|
||||
reportedAbilityId?: string,
|
||||
): DamageResult {
|
||||
const mob = work.mobs[id];
|
||||
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
|
||||
@@ -7741,12 +7806,14 @@ function damageMobInWork(
|
||||
const primaryApplied = secondaryDamage
|
||||
? Math.min(applied, Math.max(0, Math.round(primaryRemainingDamage)))
|
||||
: applied;
|
||||
// This override labels the primary event only; the ThreatSource stays authoritative for mechanics.
|
||||
const combatEventAbilityId = reportedAbilityId ?? source.abilityId;
|
||||
recordCombatEventInWork(work, {
|
||||
occurredAt: now,
|
||||
kind: "damage",
|
||||
sourceActorId: source.actorId,
|
||||
targetActorId: id,
|
||||
...(source.abilityId ? { abilityId: source.abilityId } : {}),
|
||||
...(combatEventAbilityId ? { abilityId: combatEventAbilityId } : {}),
|
||||
school,
|
||||
...(attackHand ? { attackHand } : {}),
|
||||
rawAmount: combatResult?.rawDamage !== undefined
|
||||
@@ -7799,6 +7866,7 @@ function damageMobInWork(
|
||||
const testing = useGameStore.getState().gmTestMode;
|
||||
const canAward = killed && !mob.experienceGranted && !testing;
|
||||
const beforeLevel = work.progression.level;
|
||||
const ownerCategory = useCombatStore.getState().contentCategoryId;
|
||||
const hasTreasureHunter = work.auras.some((aura) => (
|
||||
aura.targetId === PLAYER_AURA_ENTITY_ID
|
||||
&& (aura.expiresAt === null || aura.expiresAt > now)
|
||||
@@ -7807,9 +7875,10 @@ function damageMobInWork(
|
||||
const pendingLoot = testing ? [] : killed && mob.lootSourceId
|
||||
? rollBossLoot(mob.lootSourceId, beforeLevel, {
|
||||
now,
|
||||
ownerCategory,
|
||||
bonusItemChance: romRogueTreasureHunterLootChance(mob.bonusLootChance, hasTreasureHunter),
|
||||
})
|
||||
: mob.pendingLoot;
|
||||
: mob.pendingLoot.filter((item) => inventoryItemUsableForCategory(item, ownerCategory));
|
||||
const card = killed && mob.boss && !testing
|
||||
? bossCardForSource(mob.lootSourceId ?? mob.id, useGameStore.getState().activeDungeonId, mob.name)
|
||||
: null;
|
||||
@@ -9433,6 +9502,12 @@ function maybeMirrorDeathKnightDancingRuneWeaponMeleeInWork(
|
||||
source,
|
||||
school,
|
||||
work.gearStats.spellPenetration,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9546,6 +9621,7 @@ function resolveAndDamageMobInWork(
|
||||
criticalChanceBonusPercent = 0,
|
||||
weaponSpeedMs = work.gearStats.meleeWeapon?.speedMs ?? WOW335_SHAMAN_MAELSTROM_WEAPON_DEFAULT_SPEED_MS,
|
||||
selectedDbcSpellId?: number,
|
||||
reportedAbilityId?: string,
|
||||
): DamageResult {
|
||||
const mob = work.mobs[id];
|
||||
if (!mob || mob.dead || amount <= 0 || (mob.navigationEvadeUntil ?? 0) > now) {
|
||||
@@ -9913,6 +9989,7 @@ function resolveAndDamageMobInWork(
|
||||
: sourceWeaponChannel === "off-hand" ? "offhand"
|
||||
: sourceWeaponChannel === "ranged" ? "ranged" : undefined
|
||||
: undefined,
|
||||
reportedAbilityId,
|
||||
);
|
||||
maybeProcShamanFlurryInWork(
|
||||
work,
|
||||
@@ -11786,6 +11863,7 @@ function combatActorSnapshots(state: CombatState): Readonly<Record<string, Comba
|
||||
function initialState() {
|
||||
const classId: ClassId = "priest";
|
||||
const statRuleset: CharacterStatRuleset = "wow";
|
||||
const contentCategoryId: ContentCategoryId = "wow";
|
||||
const raceId: RaceId = "human";
|
||||
const gender: GenderId = "male";
|
||||
const level = 1;
|
||||
@@ -11805,6 +11883,7 @@ function initialState() {
|
||||
return {
|
||||
classId,
|
||||
statRuleset,
|
||||
contentCategoryId,
|
||||
hunterPetFamily: "wolf" as const,
|
||||
specializationId: null,
|
||||
gearRole: "healer" as PartyRole,
|
||||
@@ -11893,9 +11972,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: [];
|
||||
const talentModifiers = characterStatModifiers(classId, progression.level, talentRanks);
|
||||
const abilities = abilitiesForCharacter(classId, secondaryClassId);
|
||||
const inventory = normalizeInventory(profile.inventory);
|
||||
const equipment = normalizeEquipment(profile.equipment, inventory);
|
||||
const gearStats = specializationId !== null || classId.startsWith("rom-") ? resolvedEquipmentStats(equipment, inventory, {
|
||||
// The selected source class owns the character namespace; dungeon and
|
||||
// untrusted seed metadata cannot change which game's items are usable.
|
||||
const contentCategoryId = contentCategoryForClass(classId);
|
||||
const inventory = normalizeInventory(profile.inventory, { ownerCategory: contentCategoryId });
|
||||
const categoryInventory = inventory.filter((item) => inventoryItemUsableForCategory(item, contentCategoryId));
|
||||
const equipment = normalizeEquipment(profile.equipment, inventory, contentCategoryId);
|
||||
const gearStats = specializationId !== null || classId.startsWith("rom-") ? resolvedEquipmentStats(equipment, categoryInventory, {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
classId,
|
||||
@@ -11903,7 +11986,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
specializationId,
|
||||
role: gearRole,
|
||||
level: progression.level,
|
||||
}) : activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId);
|
||||
}) : activeEquipmentStatsForClass(equipment, inventory, PLAYER_EQUIPMENT_OWNER_ID, classId, contentCategoryId);
|
||||
const maxHealth = talentAdjustedMaxHealth(
|
||||
classId,
|
||||
progression.level,
|
||||
@@ -11952,6 +12035,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
raceId,
|
||||
gender,
|
||||
statRuleset,
|
||||
contentCategoryId,
|
||||
romNativeRecoveryClock: undefined,
|
||||
level: progression.level,
|
||||
xp: progression.xp,
|
||||
@@ -11978,7 +12062,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
abilities,
|
||||
talentRanks,
|
||||
),
|
||||
inventory,
|
||||
inventory: categoryInventory,
|
||||
bossCards: normalizeBossCardCollection(profile.bossCards),
|
||||
equipment,
|
||||
gearStats,
|
||||
@@ -11988,6 +12072,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
},
|
||||
|
||||
refreshResolvedGear: () => set((state) => {
|
||||
const categoryInventory = state.inventory.filter((item) => inventoryItemUsableForCategory(item, state.contentCategoryId));
|
||||
const context: HeirloomContext = {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
@@ -11998,8 +12083,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
level: state.level,
|
||||
};
|
||||
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-")
|
||||
? resolvedEquipmentStats(state.equipment, state.inventory, context)
|
||||
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||
? resolvedEquipmentStats(state.equipment, categoryInventory, context)
|
||||
: activeEquipmentStatsForClass(state.equipment, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId, state.contentCategoryId);
|
||||
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||
state.classId,
|
||||
state.level,
|
||||
@@ -12259,9 +12344,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
lootMob: (id, now = combatNow()) => {
|
||||
const mob = get().mobs[id];
|
||||
if (!mob || !mob.dead || !mob.hasLoot || mob.looted || mob.despawned) return false;
|
||||
const ownerCategory = get().contentCategoryId;
|
||||
const acceptedLoot = mob.pendingLoot.filter((item) => inventoryItemUsableForCategory(item, ownerCategory));
|
||||
const corpseDespawnAt = Math.max(now, (mob.diedAt ?? now) + MINIMUM_CORPSE_VISIBLE_MS);
|
||||
const lootMessage = mob.pendingLoot.length
|
||||
? `Looted ${mob.pendingLoot.map((item) => `${item.name}: ${itemStatSummary(item.stats)}`).join(", ")}.`
|
||||
const lootMessage = acceptedLoot.length
|
||||
? `Looted ${acceptedLoot.map((item) => `${item.name}: ${itemStatSummary(item.stats)}`).join(", ")}.`
|
||||
: `Looted ${mob.name}.`;
|
||||
set((state) => {
|
||||
const bossReward = mob.boss
|
||||
@@ -12269,13 +12356,13 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
id: ++bossLootRewardSequence,
|
||||
bossId: id,
|
||||
bossName: mob.name,
|
||||
items: mob.pendingLoot,
|
||||
items: acceptedLoot,
|
||||
...(mob.bossCardDrop ? { cards: mob.bossCardDrop } : {}),
|
||||
acquiredAt: now,
|
||||
} satisfies BossLootReward
|
||||
: null;
|
||||
return {
|
||||
inventory: [...state.inventory, ...mob.pendingLoot],
|
||||
inventory: [...state.inventory, ...acceptedLoot],
|
||||
bossCards: mob.bossCardDrop ? collectBossCardDrop(state.bossCards, mob.bossCardDrop) : state.bossCards,
|
||||
mobs: {
|
||||
...state.mobs,
|
||||
@@ -12332,6 +12419,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
{
|
||||
secondaryClassId: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.secondaryClassId : ownerMember?.secondaryClassId,
|
||||
level: ownerId === PLAYER_EQUIPMENT_OWNER_ID ? state.level : ownerMember?.level,
|
||||
ownerCategory: ownerId === PLAYER_EQUIPMENT_OWNER_ID
|
||||
? state.contentCategoryId
|
||||
: usePartyStore.getState().categoryId ?? contentCategoryForClass(ownerClassId),
|
||||
},
|
||||
);
|
||||
if (!change.ok) {
|
||||
@@ -12342,7 +12432,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const ownerName = ownerId === PLAYER_EQUIPMENT_OWNER_ID
|
||||
? "You"
|
||||
: ownerMember?.name ?? "Companion";
|
||||
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, state.inventory, {
|
||||
const categoryInventory = state.inventory.filter((candidate) => inventoryItemUsableForCategory(
|
||||
candidate,
|
||||
state.contentCategoryId,
|
||||
));
|
||||
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(change.assignments, categoryInventory, {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
classId: state.classId,
|
||||
@@ -12350,7 +12444,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
specializationId: state.specializationId,
|
||||
role: state.gearRole,
|
||||
level: state.level,
|
||||
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||
}) : activeEquipmentStatsForClass(change.assignments, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId, state.contentCategoryId);
|
||||
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||
state.classId,
|
||||
state.level,
|
||||
@@ -12403,7 +12497,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
),
|
||||
feedback: nextFeedback("success", `${ownerName} equipped ${item?.name ?? "the item"}.`),
|
||||
});
|
||||
usePartyStore.getState().syncEquipment(change.assignments, state.inventory);
|
||||
const partyCategory = usePartyStore.getState().categoryId;
|
||||
usePartyStore.getState().syncEquipment(
|
||||
change.assignments,
|
||||
state.inventory.filter((candidate) => inventoryItemUsableForCategory(candidate, partyCategory)),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -12411,7 +12509,11 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const state = get();
|
||||
const next = unequipInventorySlot(state.equipment, ownerId, slot);
|
||||
if (next === state.equipment) return false;
|
||||
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(next, state.inventory, {
|
||||
const categoryInventory = state.inventory.filter((candidate) => inventoryItemUsableForCategory(
|
||||
candidate,
|
||||
state.contentCategoryId,
|
||||
));
|
||||
const gearStats = state.specializationId !== null || state.classId.startsWith("rom-") ? resolvedEquipmentStats(next, categoryInventory, {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
classId: state.classId,
|
||||
@@ -12419,7 +12521,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
specializationId: state.specializationId,
|
||||
role: state.gearRole,
|
||||
level: state.level,
|
||||
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId);
|
||||
}) : activeEquipmentStatsForClass(next, state.inventory, PLAYER_EQUIPMENT_OWNER_ID, state.classId, state.contentCategoryId);
|
||||
const maxHealth = auraAdjustedPlayerMaxHealth(
|
||||
state.classId,
|
||||
state.level,
|
||||
@@ -13634,6 +13736,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
&& Object.values(state.mobs).some((mob) => mob.engaged && !mob.dead)) failure = "condition-not-met";
|
||||
else if (ability.name === "Enraged Regeneration"
|
||||
&& !state.auras.some((aura) => consumableEnrage(aura, PLAYER_AURA_ENTITY_ID, now))) failure = "condition-not-met";
|
||||
else if (ability.effects.some((effect) => effect.kind === "sacrifice-minion"
|
||||
&& !eligibleSacrificeMinion(state.summons, PLAYER_AGGRO_ID, effect, currentOrigin(state, origin), now))) failure = "condition-not-met";
|
||||
else if (state.classId === "hunter" && ability.name === "Revive Pet"
|
||||
&& !state.summons.some((summon) => summon.health <= 0
|
||||
&& (summon.id.startsWith("player-pet-")
|
||||
@@ -16848,6 +16952,22 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
]);
|
||||
}
|
||||
}
|
||||
} else if (effect.kind === "sacrifice-minion") {
|
||||
if (!sacrificeMinionInWork(work, PLAYER_AGGRO_ID, ability.id, effect, playerOrigin, now)) continue;
|
||||
const amount = Math.round(resolveAuraHealingValue(
|
||||
resolveAuraHealingValue(Math.round(work.maxHealth * effect.percentMaxHealth),
|
||||
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), "done"),
|
||||
aurasForEntity(work.auras, PLAYER_AURA_ENTITY_ID), "taken",
|
||||
));
|
||||
const healed = healPlayerInWork(work, amount);
|
||||
work.totalHealing += healed;
|
||||
recordCombatEventInWork(work, {
|
||||
occurredAt: now, kind: "healing", sourceActorId: PLAYER_AGGRO_ID,
|
||||
targetActorId: PLAYER_AURA_ENTITY_ID, abilityId: ability.id, school: spellSchool,
|
||||
rawAmount: amount, effectiveAmount: healed, overheal: amount - healed,
|
||||
absorbed: 0, critical: false,
|
||||
});
|
||||
addHealingThreatInWork(work, castThreatSource, healed, now);
|
||||
} else if (effect.kind === "execute") {
|
||||
for (const id of affectedMobIds) {
|
||||
const mob = work.mobs[id];
|
||||
@@ -18645,6 +18765,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
return applied;
|
||||
},
|
||||
|
||||
sacrificeActorMinion: (ownerActorId, abilityId, effect, origin, now = combatNow()) => {
|
||||
const state = get();
|
||||
const work = { summons: [...state.summons], actors: { ...state.actors },
|
||||
auras: [...state.auras], effects: [...state.effects], combatEvents: [...state.combatEvents] };
|
||||
if (!sacrificeMinionInWork(work, ownerActorId, abilityId, effect, origin, now)) return false;
|
||||
set(work);
|
||||
return true;
|
||||
},
|
||||
|
||||
healActor: (id, amount, request) => {
|
||||
const now = request.now ?? combatNow();
|
||||
const state = get();
|
||||
@@ -20537,7 +20666,12 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
if (remainingTicks > 0) enemyPeriodicEffects.push({ ...periodic, remainingTicks, nextTickAt });
|
||||
}
|
||||
const enemyHealing: Array<{ readonly targetId: string; readonly amount: number }> = [];
|
||||
let playerControl: { readonly mechanic: EnemyControlMechanic; readonly endsAt: number } | null = null;
|
||||
let playerControl: {
|
||||
readonly mechanic: EnemyControlMechanic;
|
||||
readonly endsAt: number;
|
||||
readonly sourceActorId: string;
|
||||
readonly causeAbilityId: string;
|
||||
} | null = null;
|
||||
const memberControls: Array<{
|
||||
readonly memberId: string;
|
||||
readonly mechanic: EnemyControlMechanic;
|
||||
@@ -21421,7 +21555,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const control = resolveEnemyTriggeredShoutControl(namedAuras, actorId, now);
|
||||
if (!control) continue;
|
||||
if (actorId === PLAYER_AGGRO_ID) {
|
||||
if (!playerControl || control.endsAt >= playerControl.endsAt) playerControl = control;
|
||||
if (!playerControl || control.endsAt >= playerControl.endsAt) {
|
||||
playerControl = { ...control, sourceActorId: id, causeAbilityId: attack.id };
|
||||
}
|
||||
} else memberControls.push({ memberId: actorId, ...control });
|
||||
}
|
||||
} else if (effect.kind === "control") {
|
||||
@@ -21482,7 +21618,9 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: auraMasteryDurationMs;
|
||||
const control = { mechanic: effect.mechanic, endsAt: now + controlDurationMs };
|
||||
if (actorId === PLAYER_AGGRO_ID) {
|
||||
if (!playerControl || control.endsAt >= playerControl.endsAt) playerControl = control;
|
||||
if (!playerControl || control.endsAt >= playerControl.endsAt) {
|
||||
playerControl = { ...control, sourceActorId: id, causeAbilityId: attack.id };
|
||||
}
|
||||
}
|
||||
else memberControls.push({ memberId: actorId, ...control });
|
||||
namedAuras = [...applyCombatAura(namedAuras, {
|
||||
@@ -21693,11 +21831,31 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
const controlInterruptedCast = playerControl && state.activeCast
|
||||
? { cast: state.activeCast, control: playerControl }
|
||||
: null;
|
||||
if (changed || playerControl || state.enemyPeriodicEffects.length || enemyPeriodicEffects.length) {
|
||||
set({
|
||||
mobs,
|
||||
enemyPeriodicEffects,
|
||||
auras: namedAuras,
|
||||
...(controlInterruptedCast ? {
|
||||
combatEvents: recordCombatEvent(state.combatEvents, {
|
||||
occurredAt: now,
|
||||
kind: "interrupt",
|
||||
sourceActorId: controlInterruptedCast.control.sourceActorId,
|
||||
targetActorId: controlInterruptedCast.cast.targetId ?? PLAYER_AURA_ENTITY_ID,
|
||||
abilityId: controlInterruptedCast.cast.abilityId,
|
||||
causeAbilityId: controlInterruptedCast.control.causeAbilityId,
|
||||
school: controlInterruptedCast.cast.school,
|
||||
rawAmount: 0,
|
||||
effectiveAmount: 0,
|
||||
overheal: 0,
|
||||
absorbed: 0,
|
||||
critical: false,
|
||||
outcome: `control:${controlInterruptedCast.control.mechanic}`,
|
||||
}),
|
||||
} : {}),
|
||||
...(playerControl
|
||||
? {
|
||||
...(state.activeCast
|
||||
@@ -21717,6 +21875,18 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
if (controlInterruptedCast) {
|
||||
emitPlayerAbilityCancellation(
|
||||
controlInterruptedCast.cast.abilityId,
|
||||
now,
|
||||
state.playerPosition,
|
||||
controlInterruptedCast.cast.targetId,
|
||||
controlInterruptedCast.cast.targetId
|
||||
? getMobPosition(controlInterruptedCast.cast.targetId)
|
||||
?? actorPosition(state, controlInterruptedCast.cast.targetId)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
let appliedDamage = 0;
|
||||
for (const hit of incomingHits) {
|
||||
if (hit.actorId === PLAYER_AURA_ENTITY_ID
|
||||
@@ -24306,13 +24476,15 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
const nativeUnarmed = mainHandDisarmed && normalizeCharacterStatRuleset(state.statRuleset, state.classId) === "rom"
|
||||
? nativeDisarmedMainHandAutoAttackProfile(deriveCharacterStats(state.classId, state.level, state.gearStats,
|
||||
state.raceId, {}, state.secondaryClassId, state.gender, state.statRuleset).romNative) : undefined;
|
||||
const explicitWarriorDualWield = playerHasExplicitWoWWarriorDualWield(state);
|
||||
const offHandWeapon = state.classId === "rogue" || state.classId === "rom-rogue"
|
||||
|| state.classId === "shaman" || state.classId === "death-knight"
|
||||
|| state.classId === "shaman" || state.classId === "death-knight" || explicitWarriorDualWield
|
||||
? playerEquippedMeleeWeapon(state, "off-hand")
|
||||
: null;
|
||||
const supportsPlayerOffhandSwings = state.classId === "rogue" || state.classId === "rom-rogue"
|
||||
|| state.classId === "shaman"
|
||||
|| (state.classId === "death-knight" && Boolean(offHandWeapon));
|
||||
|| (state.classId === "death-knight" && Boolean(offHandWeapon))
|
||||
|| explicitWarriorDualWield;
|
||||
const mainHandWeapon = supportsPlayerOffhandSwings
|
||||
? playerEquippedMeleeWeapon(state, "main-hand")
|
||||
: null;
|
||||
@@ -24376,6 +24548,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
{ isAutoAttack: true, isDualWielding },
|
||||
0,
|
||||
mainHandSpeedMs,
|
||||
undefined,
|
||||
"player-auto-attack",
|
||||
);
|
||||
const runeWeaponMainHand = state.classId === "death-knight"
|
||||
&& result.combatResult?.landed
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
equipInventoryItem,
|
||||
equipmentItemLevel,
|
||||
equipmentStats,
|
||||
activeEquipmentStatsForClass,
|
||||
normalizeEquipment,
|
||||
unequipInventorySlot,
|
||||
} from "./equipment";
|
||||
@@ -26,6 +27,7 @@ function item(
|
||||
itemId: 1,
|
||||
itemKey: instanceId,
|
||||
name: instanceId,
|
||||
contentCategory: "wow",
|
||||
quality: "uncommon",
|
||||
inventorySlot,
|
||||
armorType: null,
|
||||
@@ -66,6 +68,56 @@ describe("equipment assignments", () => {
|
||||
expect(classCanEquipItem("warlock", bow)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects foreign, unresolved, and non-native RoM gear before changing assignments", () => {
|
||||
const wowDagger = item("wow-dagger", "one-hand", { weaponType: "dagger" });
|
||||
const nativeRomDagger = item("rom-dagger", "one-hand", {
|
||||
contentCategory: "rom",
|
||||
weaponType: "dagger",
|
||||
stats: stats({ strength: 12, romNative: { strength: 12 } }),
|
||||
});
|
||||
const unresolvedRomDagger = item("unresolved-rom-dagger", "one-hand", {
|
||||
contentCategory: null,
|
||||
weaponType: "dagger",
|
||||
stats: stats({ strength: 12 }),
|
||||
});
|
||||
const foreign = equipInventoryItem({ player: { back: "kept" } }, [wowDagger], "player", "rom-priest", wowDagger.instanceId);
|
||||
expect(foreign).toEqual({
|
||||
ok: false,
|
||||
assignments: { player: { back: "kept" } },
|
||||
reason: "That item belongs to a different game category.",
|
||||
});
|
||||
expect(equipInventoryItem({}, [nativeRomDagger], "player", "rom-priest", nativeRomDagger.instanceId).ok).toBe(true);
|
||||
expect(equipInventoryItem({}, [unresolvedRomDagger], "player", "rom-priest", unresolvedRomDagger.instanceId).ok).toBe(false);
|
||||
expect(normalizeEquipment({ player: { "main-hand": wowDagger.instanceId } }, [wowDagger], "rom")).toEqual({});
|
||||
|
||||
const forgedCategory = equipInventoryItem(
|
||||
{ player: { back: "kept" } },
|
||||
[wowDagger],
|
||||
"player",
|
||||
"rom-priest",
|
||||
wowDagger.instanceId,
|
||||
{ ownerCategory: "wow" },
|
||||
);
|
||||
expect(forgedCategory.ok).toBe(false);
|
||||
expect(forgedCategory.assignments).toEqual({ player: { back: "kept" } });
|
||||
expect(classCanEquipItem("rom-priest", wowDagger, { ownerCategory: "wow" })).toBe(false);
|
||||
});
|
||||
|
||||
it("does not apply foreign or unresolved items in active equipment stat reads", () => {
|
||||
const romItem = item("rom-native", "back", {
|
||||
contentCategory: "rom",
|
||||
stats: stats({ stamina: 20, romNative: { stamina: 20 } }),
|
||||
});
|
||||
const wowItem = item("wow-foreign", "head", { stats: stats({ stamina: 90 }) });
|
||||
const assignments = { player: { back: romItem.instanceId, head: wowItem.instanceId } };
|
||||
expect(activeEquipmentStatsForClass(assignments, [romItem, wowItem], "player", "rom-priest", "rom"))
|
||||
.toMatchObject({ stamina: 20 });
|
||||
expect(activeEquipmentStatsForClass(assignments, [romItem, wowItem], "player", "rom-priest", "coa"))
|
||||
.toMatchObject({ stamina: 0 });
|
||||
expect(activeEquipmentStatsForClass(assignments, [romItem, wowItem], "player", "rom-priest", "wow"))
|
||||
.toEqual(EMPTY_ITEM_STATS);
|
||||
});
|
||||
|
||||
it("handles two-handed conflicts, unequipping, normalization, and item level", () => {
|
||||
const staff = item("staff", "two-hand", { weaponType: "staff", itemLevel: 18 });
|
||||
const shield = item("shield", "off-hand", { armorType: "shield", itemLevel: 14 });
|
||||
@@ -126,4 +178,21 @@ describe("equipment assignments", () => {
|
||||
expect(deriveRomNativeStats({ classId: "rom-warrior", level: 1, gear: unequipped.romNative }).health.max)
|
||||
.toBe(withoutGear.health.max);
|
||||
});
|
||||
|
||||
it("keeps CoA-tagged direct stats out of the RoM native projection", () => {
|
||||
const coaGear = item("coa-gear", "chest", {
|
||||
contentCategory: "coa",
|
||||
armorType: "plate",
|
||||
stats: stats({ stamina: 7, strength: 3 }),
|
||||
});
|
||||
const active = activeEquipmentStatsForClass(
|
||||
{ player: { chest: coaGear.instanceId } },
|
||||
[coaGear],
|
||||
"player",
|
||||
"guardian",
|
||||
"coa",
|
||||
);
|
||||
expect(active).toMatchObject({ stamina: 7, strength: 3 });
|
||||
expect(active.romNative).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+42
-4
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
baseClassFor,
|
||||
classById,
|
||||
contentCategoryForClass,
|
||||
type BaseClassId,
|
||||
type ClassId,
|
||||
} from "../app/characterCatalog";
|
||||
import type { ContentCategoryId } from "../app/contentCategories";
|
||||
import {
|
||||
EMPTY_ITEM_STATS,
|
||||
ITEM_STAT_KEYS,
|
||||
@@ -11,7 +13,7 @@ import {
|
||||
type NumericItemStatKey,
|
||||
type WeaponStats,
|
||||
} from "./itemStats";
|
||||
import type { InventoryItem } from "./lootTypes";
|
||||
import { inventoryItemUsableForCategory, type InventoryItem } from "./lootTypes";
|
||||
import { romNativeGearFromSharedDirectStats, sumRomNativeGearStats } from "./romNativeStatsTypes";
|
||||
|
||||
export const PLAYER_EQUIPMENT_OWNER_ID = "player";
|
||||
@@ -48,6 +50,8 @@ export interface EquipmentChange {
|
||||
export interface EquipmentClassContext {
|
||||
readonly secondaryClassId?: ClassId | null;
|
||||
readonly level?: number;
|
||||
/** Saved owner category; falls back to the owner's class category when absent. */
|
||||
readonly ownerCategory?: ContentCategoryId;
|
||||
}
|
||||
|
||||
const equipmentSlotSet = new Set<string>(EQUIPMENT_SLOTS);
|
||||
@@ -82,7 +86,15 @@ export function equipmentSlotForItem(item: InventoryItem): EquipmentSlot | null
|
||||
return equipmentSlotSet.has(slot) ? slot as EquipmentSlot : null;
|
||||
}
|
||||
|
||||
export function classCanEquipItem(classId: ClassId, item: InventoryItem, context: EquipmentClassContext = {}): boolean {
|
||||
/**
|
||||
* Internal shape-only predicate for synthetic catalog scoring. Callers must
|
||||
* still use classCanEquipItem before assigning or applying an item.
|
||||
*/
|
||||
export function classItemShapeFitsEquipmentRules(
|
||||
classId: ClassId,
|
||||
item: InventoryItem,
|
||||
context: EquipmentClassContext = {},
|
||||
): boolean {
|
||||
const slot = equipmentSlotForItem(item);
|
||||
if (!slot) return false;
|
||||
const level = context.level ?? Number.POSITIVE_INFINITY;
|
||||
@@ -111,6 +123,13 @@ export function classCanEquipItem(classId: ClassId, item: InventoryItem, context
|
||||
return true;
|
||||
}
|
||||
|
||||
export function classCanEquipItem(classId: ClassId, item: InventoryItem, context: EquipmentClassContext = {}): boolean {
|
||||
const classCategory = contentCategoryForClass(classId);
|
||||
if (context.ownerCategory !== undefined && context.ownerCategory !== classCategory) return false;
|
||||
return inventoryItemUsableForCategory(item, classCategory)
|
||||
&& classItemShapeFitsEquipmentRules(classId, item, context);
|
||||
}
|
||||
|
||||
function mutableAssignments(assignments: EquipmentAssignments): Record<string, EquipmentLoadout> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(assignments).map(([ownerId, loadout]) => [ownerId, { ...loadout }]),
|
||||
@@ -141,6 +160,14 @@ export function equipInventoryItem(
|
||||
): EquipmentChange {
|
||||
const item = inventory.find((candidate) => candidate.instanceId === instanceId);
|
||||
if (!item) return { ok: false, assignments, reason: "That item is no longer in your inventory." };
|
||||
const ownerCategory = contentCategoryForClass(ownerClassId);
|
||||
if ((classContext.ownerCategory !== undefined && classContext.ownerCategory !== ownerCategory)
|
||||
|| !inventoryItemUsableForCategory(item, ownerCategory)) {
|
||||
const reason = ownerCategory === "rom" && item.contentCategory === "rom"
|
||||
? "That item has no validated RuneWaker-native gear values."
|
||||
: "That item belongs to a different game category.";
|
||||
return { ok: false, assignments, reason };
|
||||
}
|
||||
const slot = equipmentSlotForItem(item);
|
||||
if (!slot) return { ok: false, assignments, reason: `${item.name} does not use an equippable slot.` };
|
||||
if (!classCanEquipItem(ownerClassId, item, classContext)) {
|
||||
@@ -206,6 +233,7 @@ export function equipmentItemsForOwner(
|
||||
export function normalizeEquipment(
|
||||
value: unknown,
|
||||
inventory: readonly InventoryItem[],
|
||||
ownerCategory?: ContentCategoryId,
|
||||
): EquipmentAssignments {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
|
||||
@@ -219,6 +247,7 @@ export function normalizeEquipment(
|
||||
if (!equipmentSlotSet.has(rawSlot) || typeof rawInstanceId !== "string" || assigned.has(rawInstanceId)) continue;
|
||||
const item = itemById.get(rawInstanceId);
|
||||
if (!item || equipmentSlotForItem(item) !== rawSlot) continue;
|
||||
if (ownerCategory !== undefined && !inventoryItemUsableForCategory(item, ownerCategory)) continue;
|
||||
loadout[rawSlot as EquipmentSlot] = rawInstanceId;
|
||||
assigned.add(rawInstanceId);
|
||||
}
|
||||
@@ -260,7 +289,11 @@ export function equipmentStats(
|
||||
const stats = instanceId ? itemById.get(instanceId)?.stats : null;
|
||||
if (!stats) continue;
|
||||
for (const key of ITEM_STAT_KEYS) numeric[key] += stats[key];
|
||||
const nativePayload = stats.romNative ?? romNativeGearFromSharedDirectStats(stats);
|
||||
const contentCategory = itemById.get(instanceId)?.contentCategory;
|
||||
const nativePayload = stats.romNative
|
||||
?? (contentCategory === "rom" || contentCategory === "coa"
|
||||
? undefined
|
||||
: romNativeGearFromSharedDirectStats(stats));
|
||||
if (nativePayload) nativePayloads.push(nativePayload);
|
||||
if (stats.meleeWeapon) meleeWeapon = stats.meleeWeapon;
|
||||
if (stats.rangedWeapon) rangedWeapon = stats.rangedWeapon;
|
||||
@@ -275,14 +308,19 @@ export function activeEquipmentStatsForClass(
|
||||
inventory: readonly InventoryItem[],
|
||||
ownerId: string,
|
||||
classId: ClassId,
|
||||
ownerCategory?: ContentCategoryId,
|
||||
): ItemStats {
|
||||
const classCategory = contentCategoryForClass(classId);
|
||||
if (ownerCategory !== undefined && ownerCategory !== classCategory) return EMPTY_ITEM_STATS;
|
||||
const loadout = assignments[ownerId];
|
||||
if (!loadout) return EMPTY_ITEM_STATS;
|
||||
const itemById = new Map(inventory.map((item) => [item.instanceId, item]));
|
||||
const activeAssignments: EquipmentAssignments = {
|
||||
[ownerId]: Object.fromEntries(Object.entries(loadout).filter(([, instanceId]) => {
|
||||
const item = instanceId ? itemById.get(instanceId) : null;
|
||||
return Boolean(item && classCanEquipItem(classId, item));
|
||||
return Boolean(item
|
||||
&& inventoryItemUsableForCategory(item, classCategory)
|
||||
&& classCanEquipItem(classId, item));
|
||||
})),
|
||||
};
|
||||
return equipmentStats(activeAssignments, inventory, ownerId);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClassId } from "../app/characterCatalog";
|
||||
import { contentCategoryForClass, type ClassId } from "../app/characterCatalog";
|
||||
import { classCanEquipItem, equipmentSlotForItem } from "./equipment";
|
||||
import {
|
||||
compareEquipmentUpgrade,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resolvedAverageItemLevel,
|
||||
resolvedEquipmentItems,
|
||||
resolvedEquipmentLoadout,
|
||||
resolvedEquipmentStats,
|
||||
type HeirloomContext,
|
||||
} from "./heirloomEquipment";
|
||||
import { EMPTY_ITEM_STATS, itemStatsFor } from "./itemStats";
|
||||
@@ -33,8 +34,14 @@ function context(profile: SpecializationDefinition, level: number, ownerKind: "p
|
||||
};
|
||||
}
|
||||
|
||||
function catalogItem(template: LootCatalogItem, itemLevel: number, suffix = "test"): InventoryItem {
|
||||
function catalogItem(
|
||||
template: LootCatalogItem,
|
||||
itemLevel: number,
|
||||
suffix = "test",
|
||||
overrides: Partial<InventoryItem> = {},
|
||||
): InventoryItem {
|
||||
return {
|
||||
contentCategory: "wow",
|
||||
instanceId: `${template.itemId}:${itemLevel}:${suffix}`,
|
||||
itemId: template.itemId,
|
||||
itemKey: template.itemKey,
|
||||
@@ -50,6 +57,7 @@ function catalogItem(template: LootCatalogItem, itemLevel: number, suffix = "tes
|
||||
sourceBossKey: "test-boss",
|
||||
sourceBossName: "Test Boss",
|
||||
acquiredAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,6 +85,59 @@ describe("specialization-aware heirlooms", () => {
|
||||
expect(loadout["off-hand"]?.armorType).toBe("shield");
|
||||
});
|
||||
|
||||
it("keeps RoM training defaults when foreign, unresolved, or non-native assigned items are present", () => {
|
||||
const romContext: HeirloomContext = {
|
||||
ownerId: "rom-owner",
|
||||
ownerKind: "player",
|
||||
classId: "rom-priest",
|
||||
specializationId: null,
|
||||
role: "healer",
|
||||
level: 40,
|
||||
};
|
||||
const chestTemplate = templates.find((template) => template.inventorySlot === "chest" && template.armorType === "cloth")!;
|
||||
const training = resolvedEquipmentLoadout({}, [], romContext).chest!;
|
||||
expect(training).toMatchObject({
|
||||
contentCategory: "rom",
|
||||
quality: "heirloom",
|
||||
sourceBossKey: "heirloom",
|
||||
sourceBossName: "Training Armory",
|
||||
});
|
||||
expect(training.stats.romNative).toBeDefined();
|
||||
|
||||
const trainingSlotWithoutReference = resolvedEquipmentLoadout({}, [], romContext)["off-hand"]!;
|
||||
expect(trainingSlotWithoutReference.stats.romNative).toBeUndefined();
|
||||
expect(equipmentStatsForItems([trainingSlotWithoutReference]).romNative).toBeDefined();
|
||||
const copiedTraining = JSON.parse(JSON.stringify(trainingSlotWithoutReference)) as InventoryItem;
|
||||
expect(copiedTraining).toMatchObject({
|
||||
contentCategory: "rom",
|
||||
instanceId: trainingSlotWithoutReference.instanceId,
|
||||
sourceBossName: "Training Armory",
|
||||
});
|
||||
expect(equipmentStatsForItems([copiedTraining]).romNative).toBeUndefined();
|
||||
expect(classCanEquipItem("rom-priest", copiedTraining)).toBe(false);
|
||||
|
||||
const badAssignedItems = [
|
||||
catalogItem(chestTemplate, 40, "foreign-wow"),
|
||||
catalogItem(chestTemplate, 40, "unresolved", { contentCategory: null }),
|
||||
catalogItem(chestTemplate, 40, "rom-without-native", { contentCategory: "rom" }),
|
||||
];
|
||||
for (const item of badAssignedItems) {
|
||||
expect(classCanEquipItem("rom-priest", item)).toBe(false);
|
||||
const loadout = resolvedEquipmentLoadout({ [romContext.ownerId]: { chest: item.instanceId } }, [item], romContext);
|
||||
expect(loadout.chest).toMatchObject({ sourceBossName: "Training Armory", contentCategory: "rom" });
|
||||
expect(resolvedEquipmentStats({ [romContext.ownerId]: { chest: item.instanceId } }, [item], romContext))
|
||||
.toEqual(resolvedEquipmentStats({}, [], romContext));
|
||||
}
|
||||
|
||||
const nativeItem = catalogItem(chestTemplate, 40, "rom-native", {
|
||||
contentCategory: "rom",
|
||||
stats: itemStatsFor({ ...chestTemplate, itemLevel: 40, romNative: { stamina: 3 } }),
|
||||
});
|
||||
expect(classCanEquipItem("rom-priest", nativeItem)).toBe(true);
|
||||
expect(resolvedEquipmentLoadout({ [romContext.ownerId]: { chest: nativeItem.instanceId } }, [nativeItem], romContext).chest)
|
||||
.toBe(nativeItem);
|
||||
});
|
||||
|
||||
it("allocates only useful archetype stats", () => {
|
||||
const protection = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "warrior:protection")!;
|
||||
const holy = SPECIALIZATION_DEFINITIONS.find((definition) => definition.id === "priest:holy")!;
|
||||
@@ -118,10 +179,10 @@ describe("specialization-aware heirlooms", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("opens a real item-level-85 upgrade path for every gear profile at level 80", () => {
|
||||
it("opens an item-level-85 catalog upgrade path for every WoW gear profile at level 80", () => {
|
||||
expect(scaledLootItemLevel(79)).toBe(79);
|
||||
expect(scaledLootItemLevel(80)).toBe(85);
|
||||
for (const profile of SPECIALIZATION_DEFINITIONS) {
|
||||
for (const profile of SPECIALIZATION_DEFINITIONS.filter((candidate) => contentCategoryForClass(candidate.classId) === "wow")) {
|
||||
const loadout = resolvedEquipmentLoadout({}, [], context(profile, 80));
|
||||
const upgrades = compatibleItems(profile.classId, 85)
|
||||
.filter((item) => compareEquipmentUpgrade(item, loadout, profile, context(profile, 80)).isUpgrade);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {
|
||||
contentCategoryForClass,
|
||||
classById,
|
||||
isRomClassId,
|
||||
type ClassId,
|
||||
} from "../app/characterCatalog";
|
||||
import {
|
||||
classCanEquipItem,
|
||||
classItemShapeFitsEquipmentRules,
|
||||
EQUIPMENT_SLOTS,
|
||||
equipmentSlotForItem,
|
||||
type EquipmentAssignments,
|
||||
@@ -94,6 +96,11 @@ const ARMOR_SLOTS = new Set<EquipmentSlot>([
|
||||
|
||||
const CATALOG_ITEMS = LOOT_CATALOG.bosses.flatMap((boss) => boss.items);
|
||||
const itemCache = new Map<string, InventoryItem>();
|
||||
// Training placeholders are generated internally and are never serialized as
|
||||
// earned inventory. Keep their legacy simulator projection private so it can
|
||||
// preserve the existing RoM training profile without making direct WoW stats
|
||||
// valid on a caller-supplied RoM item.
|
||||
const romTrainingItems = new WeakSet<InventoryItem>();
|
||||
|
||||
function effectiveLevel(context: HeirloomContext): number {
|
||||
const cap = context.ownerKind === "party" ? PARTY_HEIRLOOM_LEVEL_CAP : PLAYER_HEIRLOOM_LEVEL_CAP;
|
||||
@@ -181,6 +188,7 @@ function weaponStats(slot: EquipmentSlot, inventorySlot: string, weaponType: str
|
||||
|
||||
function syntheticCatalogItem(template: LootCatalogItem, itemLevel: number): InventoryItem {
|
||||
return {
|
||||
contentCategory: "wow",
|
||||
instanceId: `catalog:${template.itemId}`,
|
||||
itemId: template.itemId,
|
||||
itemKey: template.itemKey,
|
||||
@@ -203,7 +211,9 @@ function highestCatalogScore(classId: ClassId, slot: EquipmentSlot, level: numbe
|
||||
let highest = 0;
|
||||
for (const template of CATALOG_ITEMS) {
|
||||
const candidate = syntheticCatalogItem(template, level);
|
||||
if (equipmentSlotForItem(candidate) !== slot || !classCanEquipItem(classId, candidate)) continue;
|
||||
// Catalog comparison is an internal shape-only specialization baseline.
|
||||
// These WoW templates never enter inventory or an equipment assignment.
|
||||
if (equipmentSlotForItem(candidate) !== slot || !classItemShapeFitsEquipmentRules(classId, candidate)) continue;
|
||||
highest = Math.max(highest, specializationScore(candidate.stats, profile));
|
||||
}
|
||||
return highest;
|
||||
@@ -286,6 +296,7 @@ function heirloomItem(
|
||||
// internal quality so this label change cannot alter saved builds or budgets.
|
||||
const romTrainingGear = isRomClassId(context.classId);
|
||||
const result: InventoryItem = Object.freeze({
|
||||
contentCategory: contentCategoryForClass(context.classId),
|
||||
instanceId: `heirloom:${context.ownerId}:${profile.id}:${slot}`,
|
||||
itemId: 9_000_000 + stableNumber % 900_000,
|
||||
itemKey: `heirloom-${profile.id.replace(/:/g, "-")}-${slot}`,
|
||||
@@ -302,6 +313,7 @@ function heirloomItem(
|
||||
sourceBossName: romTrainingGear ? "Training Armory" : "Heirloom Armory",
|
||||
acquiredAt: 0,
|
||||
});
|
||||
if (romTrainingGear) romTrainingItems.add(result);
|
||||
itemCache.set(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
@@ -348,7 +360,12 @@ export function equipmentStatsForItems(items: readonly InventoryItem[]): ItemSta
|
||||
const nativePayloads = [] as NonNullable<ItemStats["romNative"]>[];
|
||||
for (const item of items) {
|
||||
for (const key of ITEM_STAT_KEYS) numeric[key] += item.stats[key];
|
||||
const nativePayload = item.stats.romNative ?? romNativeGearFromSharedDirectStats(item.stats);
|
||||
const nativePayload = item.stats.romNative
|
||||
?? (romTrainingItems.has(item)
|
||||
|| item.contentCategory === undefined
|
||||
|| item.contentCategory === "wow"
|
||||
? romNativeGearFromSharedDirectStats(item.stats)
|
||||
: undefined);
|
||||
if (nativePayload) nativePayloads.push(nativePayload);
|
||||
if (item.stats.meleeWeapon) meleeWeapon = item.stats.meleeWeapon;
|
||||
if (item.stats.rangedWeapon) rangedWeapon = item.stats.rangedWeapon;
|
||||
|
||||
@@ -35,6 +35,7 @@ describe("SQL-generated boss loot catalog", () => {
|
||||
|
||||
expect(first).toHaveLength(1);
|
||||
expect(first[0]).toMatchObject({
|
||||
contentCategory: "wow",
|
||||
itemId: 5404,
|
||||
name: "Serpent's Shoulders",
|
||||
itemLevel: 85,
|
||||
@@ -61,6 +62,13 @@ describe("SQL-generated boss loot catalog", () => {
|
||||
expect(rollBossLoot("not-a-boss", 20)).toEqual([]);
|
||||
});
|
||||
|
||||
it("selects source-tagged WoW drops only for a WoW-owned character", () => {
|
||||
expect(rollBossLoot("lady-anacondra", 80, { random: () => 0, ownerCategory: "wow" })[0].contentCategory)
|
||||
.toBe("wow");
|
||||
expect(rollBossLoot("lady-anacondra", 80, { random: () => 0, ownerCategory: "rom" })).toEqual([]);
|
||||
expect(rollBossLoot("lady-anacondra", 80, { random: () => 0, ownerCategory: "coa" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("adds at most one bonus weighted item without changing normal rolls", () => {
|
||||
const normal = rollBossLoot("lady-anacondra", 80, {
|
||||
random: () => 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import generatedLootCatalog from "./generated/lootCatalog.json";
|
||||
import type { ContentCategoryId } from "../app/contentCategories";
|
||||
import {
|
||||
itemStatsFor,
|
||||
type SourceItemStats,
|
||||
@@ -48,6 +49,8 @@ export interface LootRollOptions {
|
||||
readonly now?: number;
|
||||
/** A single additional weighted item roll after the authored boss rolls. */
|
||||
readonly bonusItemChance?: number;
|
||||
/** Saved character/party category; source identity is never inferred from dungeon. */
|
||||
readonly ownerCategory?: ContentCategoryId;
|
||||
}
|
||||
|
||||
export const LOOT_CATALOG = generatedLootCatalog as LootCatalog;
|
||||
@@ -104,6 +107,7 @@ export function rollBossLoot(
|
||||
if (!table?.items.length) {
|
||||
return rollRunewakerBossLoot(bossKey, playerLevel, options);
|
||||
}
|
||||
if (options.ownerCategory !== undefined && options.ownerCategory !== "wow") return [];
|
||||
const random = options.random ?? Math.random;
|
||||
const acquiredAt = options.now ?? Date.now();
|
||||
const rolls = randomInteger(table.rollsMin, table.rollsMax, random);
|
||||
@@ -117,6 +121,7 @@ export function rollBossLoot(
|
||||
itemInstanceSequence += 1;
|
||||
const scaledItemLevel = template.scalable ? itemLevel : template.sourceItemLevel;
|
||||
drops.push({
|
||||
contentCategory: "wow",
|
||||
instanceId: `${bossKey}:${acquiredAt}:${itemInstanceSequence}`,
|
||||
itemId: template.itemId,
|
||||
itemKey: template.itemKey,
|
||||
@@ -154,5 +159,5 @@ export function rollBossLoot(
|
||||
addWeightedDrop();
|
||||
}
|
||||
|
||||
return allowedLootDrops(drops, playerLevel);
|
||||
return allowedLootDrops(drops, playerLevel, options.ownerCategory);
|
||||
}
|
||||
+10
-4
@@ -1,8 +1,14 @@
|
||||
import type { InventoryItem } from "./lootTypes";
|
||||
import type { ContentCategoryId } from "../app/contentCategories";
|
||||
import { inventoryItemUsableForCategory, type InventoryItem } from "./lootTypes";
|
||||
|
||||
/** Leveling dungeon bosses award cards through the separate boss-card path. */
|
||||
export function allowedLootDrops(drops: readonly InventoryItem[], playerLevel: number): readonly InventoryItem[] {
|
||||
return Number.isFinite(playerLevel) && playerLevel >= 80
|
||||
export function allowedLootDrops(
|
||||
drops: readonly InventoryItem[],
|
||||
playerLevel: number,
|
||||
ownerCategory?: ContentCategoryId,
|
||||
): readonly InventoryItem[] {
|
||||
if (!Number.isFinite(playerLevel) || playerLevel < 80) return [];
|
||||
return ownerCategory === undefined
|
||||
? drops
|
||||
: [];
|
||||
: drops.filter((item) => inventoryItemUsableForCategory(item, ownerCategory));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { inventoryItemMatchesCategory, inventoryItemUsableForCategory, normalizeInventory } from "./lootTypes";
|
||||
|
||||
function savedItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
instanceId: "gear:1",
|
||||
itemId: 1,
|
||||
itemKey: "gear-1",
|
||||
name: "Saved Gear",
|
||||
quality: "rare",
|
||||
inventorySlot: "back",
|
||||
armorType: null,
|
||||
weaponType: null,
|
||||
itemLevel: 20,
|
||||
sourceItemLevel: 20,
|
||||
stats: EMPTY_ITEM_STATS,
|
||||
quantity: 1,
|
||||
sourceBossKey: "source-boss",
|
||||
sourceBossName: "Source Boss",
|
||||
acquiredAt: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("inventory item game-category metadata", () => {
|
||||
it("migrates only category-less WoW-owned legacy records", () => {
|
||||
expect(normalizeInventory([savedItem()], { ownerCategory: "wow" })[0].contentCategory).toBe("wow");
|
||||
expect(normalizeInventory([savedItem()], { ownerCategory: "rom" })[0].contentCategory).toBeNull();
|
||||
expect(normalizeInventory([savedItem()], { ownerCategory: "coa" })[0].contentCategory).toBeNull();
|
||||
expect(normalizeInventory([savedItem()])[0].contentCategory).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves explicit category identity, including mismatches and unresolved null", () => {
|
||||
expect(normalizeInventory([savedItem({ contentCategory: "rom" })], { ownerCategory: "wow" })[0].contentCategory)
|
||||
.toBe("rom");
|
||||
expect(normalizeInventory([savedItem({ contentCategory: "coa" })], { ownerCategory: "rom" })[0].contentCategory)
|
||||
.toBe("coa");
|
||||
expect(normalizeInventory([savedItem({ contentCategory: null })], { ownerCategory: "wow" })[0].contentCategory)
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
it("requires an explicit category and validated native payload for usable RoM gear", () => {
|
||||
const romNative = normalizeInventory([savedItem({
|
||||
contentCategory: "rom",
|
||||
stats: { ...EMPTY_ITEM_STATS, romNative: { strength: 7 } },
|
||||
})])[0];
|
||||
const romWithoutNative = normalizeInventory([savedItem({ contentCategory: "rom" })])[0];
|
||||
expect(inventoryItemMatchesCategory(romNative, "rom")).toBe(true);
|
||||
expect(inventoryItemMatchesCategory(romNative, undefined)).toBe(false);
|
||||
expect(inventoryItemUsableForCategory(romNative, "rom")).toBe(true);
|
||||
expect(inventoryItemUsableForCategory(romWithoutNative, "rom")).toBe(false);
|
||||
expect(inventoryItemUsableForCategory(romNative, "wow")).toBe(false);
|
||||
});
|
||||
});
|
||||
+40
-1
@@ -4,11 +4,15 @@ import {
|
||||
type ItemQuality,
|
||||
type ItemStats,
|
||||
} from "./itemStats";
|
||||
import { normalizeRomNativeGearStats } from "./romNativeStatsTypes";
|
||||
import type { ContentCategoryId } from "../app/contentCategories";
|
||||
|
||||
export type { ItemQuality, ItemStats } from "./itemStats";
|
||||
|
||||
/** A concrete item owned by a character, distinct from its SQL item template. */
|
||||
export interface InventoryItem {
|
||||
/** Source-game namespace. Missing legacy metadata is normalized with the owning profile. */
|
||||
readonly contentCategory?: ContentCategoryId | null;
|
||||
readonly instanceId: string;
|
||||
readonly itemId: number;
|
||||
readonly itemKey: string;
|
||||
@@ -31,8 +35,37 @@ export interface InventoryItem {
|
||||
|
||||
const ITEM_QUALITIES = new Set<ItemQuality>(["common", "uncommon", "rare", "epic", "legendary"]);
|
||||
|
||||
export interface NormalizeInventoryOptions {
|
||||
/** Saved character/party owner; dungeon and loot-source identities are not owner categories. */
|
||||
readonly ownerCategory?: ContentCategoryId;
|
||||
}
|
||||
|
||||
const CONTENT_CATEGORIES = new Set<ContentCategoryId>(["wow", "rom", "coa"]);
|
||||
|
||||
/** Returns true only for an explicit item/owner namespace match. */
|
||||
export function inventoryItemMatchesCategory(
|
||||
item: Pick<InventoryItem, "contentCategory">,
|
||||
expectedCategory: ContentCategoryId | undefined,
|
||||
): boolean {
|
||||
return expectedCategory !== undefined && item.contentCategory === expectedCategory;
|
||||
}
|
||||
|
||||
/** A RoM item is usable only when it carries nonempty validated native values. */
|
||||
export function inventoryItemUsableForCategory(
|
||||
item: Pick<InventoryItem, "contentCategory" | "stats">,
|
||||
expectedCategory: ContentCategoryId | undefined,
|
||||
): boolean {
|
||||
if (!inventoryItemMatchesCategory(item, expectedCategory)) return false;
|
||||
if (expectedCategory !== "rom") return true;
|
||||
const native = normalizeRomNativeGearStats(item.stats?.romNative);
|
||||
return Boolean(native && Object.keys(native).length > 0);
|
||||
}
|
||||
|
||||
/** Safely migrates persisted profiles created before inventory existed. */
|
||||
export function normalizeInventory(value: unknown): InventoryItem[] {
|
||||
export function normalizeInventory(
|
||||
value: unknown,
|
||||
options: NormalizeInventoryOptions = {},
|
||||
): InventoryItem[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const normalized: InventoryItem[] = [];
|
||||
for (const candidate of value) {
|
||||
@@ -53,7 +86,13 @@ export function normalizeInventory(value: unknown): InventoryItem[] {
|
||||
|| !Number.isFinite(item.acquiredAt)
|
||||
) continue;
|
||||
const itemLevel = Math.max(1, Math.min(MAX_PERSISTED_ITEM_LEVEL, Math.trunc(item.itemLevel!)));
|
||||
const contentCategory = CONTENT_CATEGORIES.has(item.contentCategory as ContentCategoryId)
|
||||
? item.contentCategory as ContentCategoryId
|
||||
: item.contentCategory === undefined && options.ownerCategory === "wow"
|
||||
? "wow"
|
||||
: null;
|
||||
normalized.push({
|
||||
contentCategory,
|
||||
instanceId: item.instanceId,
|
||||
itemId: Math.max(1, Math.trunc(item.itemId!)),
|
||||
itemKey: item.itemKey,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { eligibleSacrificeMinion } from "./sacrificeMinion";
|
||||
import { healerManaRecoveryAmount } from "./healerManaRecovery";
|
||||
import { nativeDisarmedMainHandAutoAttackProfile, nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount";
|
||||
import { romNativePlayerHitProfile } from "./romNativeCombatResolution";
|
||||
@@ -2666,6 +2667,8 @@ export function partyAbilityTargetIsValid(
|
||||
|| (metamorphosisActive && !warlockMetamorphosisCanCastAbilityWhileActive(ability))) return false;
|
||||
}
|
||||
const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition;
|
||||
if (ability.effects.some((effect) => effect.kind === "sacrifice-minion"
|
||||
&& !eligibleSacrificeMinion(combat.summons, member.id, effect, origin, now))) return false;
|
||||
const meditationAllowsMovement = romMageWarriorMeditationPathAllowsMovement(
|
||||
member.classId,
|
||||
member.secondaryClassId,
|
||||
@@ -3190,7 +3193,7 @@ function choosePartySupportIntent(
|
||||
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
|
||||
friendlyTargets,
|
||||
maintainedAbilityIds,
|
||||
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind)
|
||||
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield", "sacrifice-minion"].includes(effect.kind)
|
||||
|| (effect.kind === "apply-aura" && Boolean(effect.aura.absorbs?.length)))
|
||||
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
|
||||
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
|
||||
@@ -4587,6 +4590,17 @@ function executePartyAbility(
|
||||
ability.id,
|
||||
now,
|
||||
);
|
||||
} else if (effect.kind === "sacrifice-minion") {
|
||||
if (!useCombatStore.getState().sacrificeActorMinion(member.id, ability.id, effect, origin, now)) continue;
|
||||
const result = useCombatStore.getState().healActor(member.id,
|
||||
Math.round(member.maxHealth * effect.percentMaxHealth), {
|
||||
sourceActorId: member.id, abilityId: ability.id, school: spellSchool,
|
||||
criticalChance: 0, now,
|
||||
});
|
||||
effectiveAmount += result.effectiveAmount;
|
||||
directHealingRawAmount += result.rawAmount;
|
||||
combat = useCombatStore.getState();
|
||||
runtimeMember = memberFromCompanionActor(runtimeMember, combat.actors[member.id]);
|
||||
} else if (effect.kind === "execute") {
|
||||
for (const hostileTargetId of hostileTargets) {
|
||||
const target = useCombatStore.getState().mobs[hostileTargetId];
|
||||
@@ -5147,7 +5161,7 @@ function executePartyAbility(
|
||||
}
|
||||
emitPartyAbility(member, ability, targetId, "release", now, !castStartAlreadyEmitted);
|
||||
emitPartyAbility(member, ability, targetId, "impact", now, !castStartAlreadyEmitted);
|
||||
if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection"].includes(effect.kind))) {
|
||||
if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection", "sacrifice-minion"].includes(effect.kind))) {
|
||||
useCombatStore.getState().addHealingThreat({ actorId: member.id, role: member.role, abilityId: ability.id }, effectiveAmount);
|
||||
}
|
||||
if (suddenDoomTrigger && depth === 0) {
|
||||
|
||||
+286
@@ -0,0 +1,286 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { applyAura as applyCombatAura } from "./combatAuras";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||
import { PLAYER_EQUIPMENT_OWNER_ID } from "./equipment";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import type { InventoryItem } from "./lootTypes";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { resolvedEquipmentLoadout } from "./heirloomEquipment";
|
||||
import {
|
||||
WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID,
|
||||
WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_AURA,
|
||||
} from "./wow335DeathKnightDancingRuneWeaponRuntime";
|
||||
|
||||
const TARGET_ID = "player-auto-event-lineage-target";
|
||||
|
||||
let presentationEvents: CombatPresentationEvent[] = [];
|
||||
let publishedCombatEvents: StructuredCombatEvent[] = [];
|
||||
let unsubscribePresentation = () => {};
|
||||
let unsubscribeCombatEvents = () => {};
|
||||
|
||||
function weapon(
|
||||
instanceId: string,
|
||||
inventorySlot: InventoryItem["inventorySlot"],
|
||||
overrides: Partial<Pick<InventoryItem, "inventorySlot" | "armorType" | "weaponType">> = {},
|
||||
) {
|
||||
return {
|
||||
contentCategory: "wow" as const,
|
||||
instanceId,
|
||||
itemId: 1,
|
||||
itemKey: instanceId,
|
||||
name: instanceId,
|
||||
quality: "uncommon" as const,
|
||||
inventorySlot: overrides.inventorySlot ?? inventorySlot,
|
||||
armorType: overrides.armorType ?? null,
|
||||
weaponType: overrides.weaponType === undefined ? "dagger" : overrides.weaponType,
|
||||
itemLevel: 1,
|
||||
sourceItemLevel: 1,
|
||||
stats: {
|
||||
...EMPTY_ITEM_STATS,
|
||||
hitRating: 10_000,
|
||||
meleeWeapon: { damageMin: 100, damageMax: 100, damageSchool: "physical" as const, speedMs: 2_000 },
|
||||
},
|
||||
quantity: 1,
|
||||
sourceBossKey: "test",
|
||||
sourceBossName: "Test",
|
||||
acquiredAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function warriorAutoAttackEvents(options: {
|
||||
level?: number;
|
||||
mainHand?: InventoryItem;
|
||||
offHand?: InventoryItem;
|
||||
assignMainHand?: boolean;
|
||||
assignOffHand?: boolean;
|
||||
virtualDualWieldHeirloom?: boolean;
|
||||
} = {}) {
|
||||
const mainHand = options.mainHand ?? weapon("warrior-main-hand", "one-hand", { weaponType: "dagger" });
|
||||
const offHand = options.offHand ?? weapon("warrior-off-hand", "off-hand", { weaponType: "dagger" });
|
||||
const assignments = {
|
||||
...(options.assignMainHand === false ? {} : { "main-hand": mainHand.instanceId }),
|
||||
...(options.assignOffHand === false ? {} : { "off-hand": offHand.instanceId }),
|
||||
};
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "warrior",
|
||||
level: options.level ?? 20,
|
||||
statRuleset: "wow",
|
||||
specializationId: options.virtualDualWieldHeirloom ? "warrior:fury" : undefined,
|
||||
inventory: [mainHand, offHand],
|
||||
equipment: { [PLAYER_EQUIPMENT_OWNER_ID]: assignments },
|
||||
});
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||
name: "Player auto event lineage target",
|
||||
level: 80,
|
||||
maxHealth: 1_000_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition(TARGET_ID, [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob(TARGET_ID);
|
||||
useCombatStore.setState({
|
||||
autoAttackTargetId: TARGET_ID,
|
||||
nextAutoAttackAt: 1_000,
|
||||
nextOffhandAutoAttackAt: 1_000,
|
||||
});
|
||||
useCombatStore.getState().tick(0, 1_000);
|
||||
return useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID
|
||||
&& event.targetActorId === TARGET_ID && event.occurredAt === 1_000
|
||||
&& (event.attackHand === "mainhand" || event.attackHand === "offhand")
|
||||
));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 80 });
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||
name: "Player auto event lineage target",
|
||||
level: 80,
|
||||
maxHealth: 1_000_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition(TARGET_ID, [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob(TARGET_ID);
|
||||
presentationEvents = [];
|
||||
publishedCombatEvents = [];
|
||||
unsubscribePresentation = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event") presentationEvents.push(message.event);
|
||||
});
|
||||
unsubscribeCombatEvents = subscribeCombatEvents((event) => publishedCombatEvents.push(event));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unsubscribePresentation();
|
||||
unsubscribeCombatEvents();
|
||||
});
|
||||
|
||||
describe("player auto-attack event lineage", () => {
|
||||
it("enables WoW Warrior off-hand swings only with two assigned legal one-hand weapons at level 20", () => {
|
||||
const validWarriorSwings = warriorAutoAttackEvents();
|
||||
expect(validWarriorSwings).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ abilityId: "player-auto-attack", attackHand: "mainhand" }),
|
||||
expect.objectContaining({ abilityId: "player-auto-attack-offhand", attackHand: "offhand" }),
|
||||
]));
|
||||
expect(validWarriorSwings).toHaveLength(2);
|
||||
|
||||
const belowTrainingLevel = warriorAutoAttackEvents({ level: 19 });
|
||||
expect(belowTrainingLevel.some((event) => event.attackHand === "offhand")).toBe(false);
|
||||
|
||||
const noMainHandAssignment = warriorAutoAttackEvents({ assignMainHand: false });
|
||||
expect(noMainHandAssignment.some((event) => event.attackHand === "offhand")).toBe(false);
|
||||
|
||||
const noOffHandAssignment = warriorAutoAttackEvents({ assignOffHand: false });
|
||||
expect(noOffHandAssignment.some((event) => event.attackHand === "offhand")).toBe(false);
|
||||
|
||||
const noExplicitLoadout = warriorAutoAttackEvents({ assignMainHand: false, assignOffHand: false });
|
||||
expect(noExplicitLoadout.some((event) => event.attackHand === "offhand")).toBe(false);
|
||||
|
||||
const virtualDualWieldHeirloom = warriorAutoAttackEvents({
|
||||
assignMainHand: false,
|
||||
assignOffHand: false,
|
||||
virtualDualWieldHeirloom: true,
|
||||
});
|
||||
const state = useCombatStore.getState();
|
||||
const generatedLoadout = resolvedEquipmentLoadout(state.equipment, state.inventory, {
|
||||
ownerId: PLAYER_EQUIPMENT_OWNER_ID,
|
||||
ownerKind: "player",
|
||||
classId: state.classId,
|
||||
secondaryClassId: state.secondaryClassId,
|
||||
specializationId: state.specializationId,
|
||||
role: state.gearRole,
|
||||
level: state.level,
|
||||
});
|
||||
expect(state.specializationId).toBe("warrior:fury");
|
||||
expect(generatedLoadout["off-hand"]).toMatchObject({ inventorySlot: "off-hand", weaponType: "dagger" });
|
||||
expect(virtualDualWieldHeirloom.some((event) => event.attackHand === "offhand")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects shields, two-hand mains, and class-illegal off-hand weapons", () => {
|
||||
const shield = weapon("warrior-offhand-shield", "off-hand", { armorType: "shield", weaponType: "dagger" });
|
||||
const shieldedSwings = warriorAutoAttackEvents({ offHand: shield });
|
||||
expect(shieldedSwings.some((event) => event.attackHand === "offhand")).toBe(false);
|
||||
|
||||
const twoHandMain = weapon("warrior-two-hand-main", "two-hand", { weaponType: "dagger" });
|
||||
const twoHandSwings = warriorAutoAttackEvents({ mainHand: twoHandMain });
|
||||
expect(twoHandSwings.some((event) => event.attackHand === "offhand")).toBe(false);
|
||||
|
||||
const illegalOffHand = weapon("warrior-illegal-offhand", "off-hand", { weaponType: "wand" });
|
||||
const illegalSwings = warriorAutoAttackEvents({ offHand: illegalOffHand });
|
||||
expect(illegalSwings.some((event) => event.attackHand === "offhand")).toBe(false);
|
||||
});
|
||||
|
||||
it("joins both hand events to their releases without relabeling the triggering melee ability", () => {
|
||||
const mainHand = weapon("event-lineage-main-hand", "one-hand");
|
||||
const offHand = weapon("event-lineage-off-hand", "off-hand");
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "rogue",
|
||||
level: 80,
|
||||
inventory: [mainHand, offHand],
|
||||
equipment: { [PLAYER_EQUIPMENT_OWNER_ID]: {
|
||||
"main-hand": mainHand.instanceId,
|
||||
"off-hand": offHand.instanceId,
|
||||
} },
|
||||
});
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||
name: "Player auto event lineage target",
|
||||
level: 80,
|
||||
maxHealth: 1_000_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition(TARGET_ID, [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob(TARGET_ID);
|
||||
const triggeringAbility = useCombatStore.getState().abilities.find((ability) => ability.name === "Sinister Strike");
|
||||
expect(triggeringAbility).toBeDefined();
|
||||
expect(useCombatStore.getState().castAbility(triggeringAbility!.id, undefined, 1_000).ok).toBe(true);
|
||||
const healthBeforeSwings = useCombatStore.getState().mobs[TARGET_ID]!.health;
|
||||
|
||||
useCombatStore.getState().tick(0, 1_000);
|
||||
|
||||
const swingEvents = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID
|
||||
&& event.targetActorId === TARGET_ID && event.occurredAt === 1_000
|
||||
&& (event.attackHand === "mainhand" || event.attackHand === "offhand")
|
||||
));
|
||||
expect(swingEvents).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ abilityId: "player-auto-attack", attackHand: "mainhand" }),
|
||||
expect.objectContaining({ abilityId: "player-auto-attack-offhand", attackHand: "offhand" }),
|
||||
]));
|
||||
expect(swingEvents).toHaveLength(2);
|
||||
expect(swingEvents.reduce((sum, event) => sum + event.effectiveAmount, 0))
|
||||
.toBe(healthBeforeSwings - useCombatStore.getState().mobs[TARGET_ID]!.health);
|
||||
expect(useCombatStore.getState().combatEvents).toContainEqual(expect.objectContaining({
|
||||
kind: "damage",
|
||||
sourceActorId: PLAYER_AGGRO_ID,
|
||||
targetActorId: TARGET_ID,
|
||||
abilityId: triggeringAbility!.id,
|
||||
}));
|
||||
expect(presentationEvents).toContainEqual(expect.objectContaining({
|
||||
occurredAt: 1_000,
|
||||
phase: "release",
|
||||
targetActorId: TARGET_ID,
|
||||
abilityId: "player-auto-attack",
|
||||
}));
|
||||
expect(presentationEvents).toContainEqual(expect.objectContaining({
|
||||
occurredAt: 1_000,
|
||||
phase: "release",
|
||||
targetActorId: TARGET_ID,
|
||||
abilityId: "player-auto-attack-offhand",
|
||||
}));
|
||||
expect(publishedCombatEvents.filter((event) => event.kind === "damage"
|
||||
&& event.sourceActorId === PLAYER_AGGRO_ID && event.targetActorId === TARGET_ID
|
||||
&& (event.abilityId === "player-auto-attack" || event.abilityId === "player-auto-attack-offhand")))
|
||||
.toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps Dancing Rune Weapon's mirrored damage under its proc identity", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80 });
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||
name: "Player auto event lineage target",
|
||||
level: 80,
|
||||
maxHealth: 1_000_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition(TARGET_ID, [2, 0, 0]);
|
||||
const aura = applyCombatAura(useCombatStore.getState().auras, {
|
||||
definition: WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_AURA,
|
||||
sourceId: PLAYER_AGGRO_ID,
|
||||
targetId: PLAYER_AGGRO_ID,
|
||||
now: 1_000,
|
||||
});
|
||||
useCombatStore.setState({
|
||||
auras: [...aura.auras],
|
||||
autoAttackTargetId: TARGET_ID,
|
||||
nextAutoAttackAt: 1_000,
|
||||
});
|
||||
const healthBeforeSwing = useCombatStore.getState().mobs[TARGET_ID]!.health;
|
||||
|
||||
useCombatStore.getState().tick(0, 1_000);
|
||||
|
||||
const playerDamage = useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "damage" && event.sourceActorId === PLAYER_AGGRO_ID
|
||||
&& event.targetActorId === TARGET_ID && event.occurredAt === 1_000
|
||||
));
|
||||
expect(playerDamage).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ abilityId: "player-auto-attack", attackHand: "mainhand" }),
|
||||
expect.objectContaining({
|
||||
abilityId: WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID,
|
||||
}),
|
||||
]));
|
||||
expect(playerDamage.find((event) => event.abilityId
|
||||
=== WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID)).not.toHaveProperty("attackHand");
|
||||
expect(playerDamage).toHaveLength(2);
|
||||
expect(playerDamage.reduce((sum, event) => sum + event.effectiveAmount, 0))
|
||||
.toBe(healthBeforeSwing - useCombatStore.getState().mobs[TARGET_ID]!.health);
|
||||
expect(presentationEvents.filter((event) => event.phase === "release"
|
||||
&& event.abilityId === "player-auto-attack")).toHaveLength(1);
|
||||
expect(presentationEvents.some((event) => event.phase === "release"
|
||||
&& event.abilityId === WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID)).toBe(false);
|
||||
});
|
||||
});
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import { subscribeCombatEvents, type StructuredCombatEvent } from "./combatEvents";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import type { EnemyAttackDefinition } from "./enemyAttacks";
|
||||
|
||||
const TARGET_ID = "player-control-cancellation-target";
|
||||
const CONTROL_MOB_ID = "player-control-cancellation-source";
|
||||
|
||||
let presentationEvents: CombatPresentationEvent[] = [];
|
||||
let publishedCombatEvents: StructuredCombatEvent[] = [];
|
||||
let unsubscribePresentation = () => {};
|
||||
let unsubscribeCombatEvents = () => {};
|
||||
|
||||
function prepareCharacter(classId: "priest" | "warlock"): void {
|
||||
useCombatStore.getState().initializeCharacter({ classId, level: 80 });
|
||||
useCombatStore.getState().setPlayerPosition([0, 0, 0]);
|
||||
useCombatStore.getState().registerMob(TARGET_ID, {
|
||||
name: "Player control cancellation target",
|
||||
level: 80,
|
||||
maxHealth: 1_000_000,
|
||||
xpReward: 0,
|
||||
});
|
||||
setMobPosition(TARGET_ID, [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob(TARGET_ID);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearMobRuntimeRegistry();
|
||||
prepareCharacter("priest");
|
||||
presentationEvents = [];
|
||||
publishedCombatEvents = [];
|
||||
unsubscribePresentation = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event") presentationEvents.push(message.event);
|
||||
});
|
||||
unsubscribeCombatEvents = subscribeCombatEvents((event) => publishedCombatEvents.push(event));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
unsubscribePresentation();
|
||||
unsubscribeCombatEvents();
|
||||
});
|
||||
|
||||
function startTargetingCast(abilityId: string, now = 1_000) {
|
||||
const result = useCombatStore.getState().castAbility(abilityId, {
|
||||
targetActorId: TARGET_ID,
|
||||
origin: [0, 0, 0],
|
||||
now,
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(useCombatStore.getState().activeCast).toMatchObject({ abilityId, targetId: TARGET_ID });
|
||||
return useCombatStore.getState().activeCast!;
|
||||
}
|
||||
|
||||
function deliverControl(now: number): void {
|
||||
if (!useCombatStore.getState().mobs[CONTROL_MOB_ID]) {
|
||||
const attack: EnemyAttackDefinition = {
|
||||
id: "lineage-control-stun",
|
||||
name: "Lineage Control Stun",
|
||||
spellId: 987_654,
|
||||
delivery: "projectile",
|
||||
target: "primary",
|
||||
school: "shadow",
|
||||
animation: "cast",
|
||||
castTimeMs: 0,
|
||||
range: 30,
|
||||
cooldownMs: 4_000,
|
||||
initialCooldownMs: [0, 0],
|
||||
repeatCooldownMs: [4_000, 4_000],
|
||||
damageMultiplier: 0,
|
||||
effect: { kind: "control", mechanic: "stun", durationMs: 2_000 },
|
||||
};
|
||||
useCombatStore.getState().registerMob(CONTROL_MOB_ID, {
|
||||
name: "Lineage control source",
|
||||
level: 80,
|
||||
maxHealth: 1_000_000,
|
||||
xpReward: 0,
|
||||
attacks: [attack],
|
||||
});
|
||||
setMobPosition(CONTROL_MOB_ID, [2, 0, 1]);
|
||||
useCombatStore.getState().damageMob(CONTROL_MOB_ID, 1, now);
|
||||
}
|
||||
useCombatStore.getState().advanceMobCombat(now);
|
||||
}
|
||||
|
||||
function interruptionsFor(abilityId: string): StructuredCombatEvent[] {
|
||||
return useCombatStore.getState().combatEvents.filter((event) => (
|
||||
event.kind === "interrupt" && event.abilityId === abilityId
|
||||
));
|
||||
}
|
||||
|
||||
describe("player cast cancellation from enemy control", () => {
|
||||
it("records one stun interruption with the source, original target, and canceled cast identity", () => {
|
||||
const cast = startTargetingCast("priest-smite");
|
||||
|
||||
deliverControl(1_000);
|
||||
|
||||
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||
expect(useCombatStore.getState().controlMechanic).toBe("stun");
|
||||
expect(interruptionsFor(cast.abilityId)).toEqual([expect.objectContaining({
|
||||
occurredAt: 1_000,
|
||||
sourceActorId: CONTROL_MOB_ID,
|
||||
targetActorId: cast.targetId,
|
||||
abilityId: cast.abilityId,
|
||||
outcome: "control:stun",
|
||||
causeAbilityId: "lineage-control-stun",
|
||||
})]);
|
||||
expect(presentationEvents.filter((event) => event.phase === "cast-cancel"
|
||||
&& event.abilityId === cast.abilityId)).toEqual([expect.objectContaining({
|
||||
targetActorId: cast.targetId,
|
||||
occurredAt: 1_000,
|
||||
})]);
|
||||
|
||||
deliverControl(1_100);
|
||||
|
||||
expect(interruptionsFor(cast.abilityId)).toHaveLength(1);
|
||||
expect(publishedCombatEvents.filter((event) => event.kind === "interrupt"
|
||||
&& event.abilityId === cast.abilityId)).toHaveLength(1);
|
||||
expect(presentationEvents.filter((event) => event.phase === "cast-cancel"
|
||||
&& event.abilityId === cast.abilityId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("cancels a channel once without creating a release", () => {
|
||||
prepareCharacter("warlock");
|
||||
const cast = startTargetingCast("warlock-drain-life");
|
||||
expect(cast.mode).toBe("channel");
|
||||
|
||||
deliverControl(1_000);
|
||||
|
||||
expect(interruptionsFor(cast.abilityId)).toHaveLength(1);
|
||||
expect(presentationEvents.filter((event) => event.abilityId === cast.abilityId
|
||||
&& event.phase === "cast-cancel")).toHaveLength(1);
|
||||
expect(presentationEvents.some((event) => event.abilityId === cast.abilityId
|
||||
&& event.phase === "release")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not fabricate a control interruption after normal completion or death", () => {
|
||||
const completedCast = startTargetingCast("priest-smite");
|
||||
useCombatStore.getState().tick(0, completedCast.completesAt);
|
||||
|
||||
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||
expect(interruptionsFor(completedCast.abilityId)).toHaveLength(0);
|
||||
expect(presentationEvents.some((event) => event.abilityId === completedCast.abilityId
|
||||
&& event.phase === "release")).toBe(true);
|
||||
expect(presentationEvents.some((event) => event.abilityId === completedCast.abilityId
|
||||
&& event.phase === "cast-cancel")).toBe(false);
|
||||
|
||||
prepareCharacter("priest");
|
||||
const doomedCast = startTargetingCast("priest-smite", 4_000);
|
||||
const health = useCombatStore.getState().health;
|
||||
useCombatStore.getState().damagePlayer(health * 10, "physical", undefined, 4_001);
|
||||
expect(useCombatStore.getState().health).toBe(0);
|
||||
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||
deliverControl(4_001);
|
||||
expect(interruptionsFor(doomedCast.abilityId)).toHaveLength(0);
|
||||
expect(presentationEvents.some((event) => event.abilityId === doomedCast.abilityId
|
||||
&& event.phase === "cast-cancel")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -145,7 +145,7 @@ describe("RoM five class validation scope", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("equips the role defining weapons in the manual cohort", () => {
|
||||
it("keeps role-defined training slots separate from earned RoM gear", () => {
|
||||
for (const { classId, role, layout } of CASES) {
|
||||
expect(romGearProfile(classId, role).weaponLayout).toBe(layout);
|
||||
const loadout = resolvedEquipmentLoadout({}, [], {
|
||||
@@ -184,14 +184,18 @@ describe("RoM five class validation scope", () => {
|
||||
expect(loadout.ranged).toBeUndefined();
|
||||
}
|
||||
expect(loadout.wrist).toBeUndefined();
|
||||
for (const item of Object.values(loadout)) expect(classCanEquipItem(classId, item!, {
|
||||
secondaryClassId: ROM_FIVE_IDENTITIES.find((identity) => identity.classId === classId)?.secondaryClassId,
|
||||
level: 60,
|
||||
})).toBe(true);
|
||||
for (const item of Object.values(loadout)) {
|
||||
expect(item!.contentCategory).toBe("rom");
|
||||
const hasNativePayload = Boolean(item!.stats.romNative && Object.keys(item!.stats.romNative).length > 0);
|
||||
expect(classCanEquipItem(classId, item!, {
|
||||
secondaryClassId: ROM_FIVE_IDENTITIES.find((identity) => identity.classId === classId)?.secondaryClassId,
|
||||
level: 60,
|
||||
})).toBe(hasNativePayload);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("opens the independent ranged and off-hand positions at the source training levels", () => {
|
||||
it("opens training weapon slots without making placeholders equippable inventory", () => {
|
||||
const loadout = (classId: "rom-knight" | "rom-scout" | "rom-rogue", secondaryClassId: "rom-scout" | "rom-rogue" | "rom-warrior", level: number) =>
|
||||
resolvedEquipmentLoadout({}, [], {
|
||||
ownerId: `rom-slots:${classId}`,
|
||||
@@ -213,7 +217,7 @@ describe("RoM five class validation scope", () => {
|
||||
const knightBow = loadout("rom-knight", "rom-scout", 60).ranged!;
|
||||
expect(classCanEquipItem("rom-knight", knightBow, { level: 60 })).toBe(false);
|
||||
expect(equipInventoryItem({}, [knightBow], "knight", "rom-knight", knightBow.instanceId,
|
||||
{ secondaryClassId: "rom-scout", level: 60 }).ok).toBe(true);
|
||||
{ secondaryClassId: "rom-scout", level: 60 }).ok).toBe(false);
|
||||
expect(equipInventoryItem({}, [knightBow], "knight", "rom-knight", knightBow.instanceId,
|
||||
{ secondaryClassId: "rom-priest", level: 60 }).ok).toBe(false);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ describe("RuneWaker source loot catalog", () => {
|
||||
now: 12_345,
|
||||
});
|
||||
expect(drops[0]).toMatchObject({
|
||||
contentCategory: "rom",
|
||||
itemId: 200004,
|
||||
name: "Healing Sigil",
|
||||
quantity: 1,
|
||||
@@ -28,11 +29,18 @@ describe("RuneWaker source loot catalog", () => {
|
||||
inventorySlot: "misc",
|
||||
});
|
||||
expect(drops[0].stats).toMatchObject({ stamina: 0, armor: 0, meleeWeapon: null });
|
||||
expect(drops[0].stats.romNative).toBeUndefined();
|
||||
expect(rollRunewakerBossLoot("rw-barren-caves-101462", 37, {
|
||||
random: () => 0,
|
||||
})).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps source-tagged generic outcomes out of gear when an owner category is supplied", () => {
|
||||
const options = { random: () => 0, now: 12_346, ownerCategory: "rom" as const };
|
||||
expect(rollRunewakerBossLoot("rw-barren-caves-101462", 80, options)).toEqual([]);
|
||||
expect(rollRunewakerBossLoot("rw-barren-caves-101462", 80, { ...options, ownerCategory: "wow" })).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps no-source tables and unknown bosses fail closed", () => {
|
||||
const noDrops = RUNEWAKER_LOOT_CATALOG.bosses.find((boss) => boss.status === "no-source-drops")!;
|
||||
expect(runewakerBossHasLoot(noDrops.bossKey)).toBe(false);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import generatedCatalog from "./generated/runewakerLootCatalog.json";
|
||||
import type { ContentCategoryId } from "../app/contentCategories";
|
||||
import { allowedLootDrops } from "./lootPolicy";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import type { InventoryItem } from "./lootTypes";
|
||||
@@ -51,6 +52,8 @@ export interface RunewakerLootRollOptions {
|
||||
readonly levelDelta?: number;
|
||||
/** HealerMan gameplay modifier: one extra authored slot roll may be attempted. */
|
||||
readonly bonusItemChance?: number;
|
||||
/** Saved character/party category; dungeon category is not used as owner identity. */
|
||||
readonly ownerCategory?: ContentCategoryId;
|
||||
}
|
||||
|
||||
export const RUNEWAKER_LOOT_CATALOG = generatedCatalog as RunewakerLootCatalog;
|
||||
@@ -101,6 +104,7 @@ function rollSourceSlot(
|
||||
const itemLevel = Math.max(1, Math.min(255, Math.trunc(playerLevel)));
|
||||
itemInstanceSequence += 1;
|
||||
return Object.freeze({
|
||||
contentCategory: "rom",
|
||||
instanceId: `${boss.bossKey}:rw:${acquiredAt}:${itemInstanceSequence}`,
|
||||
itemId: outcome.objectId,
|
||||
itemKey: `runewaker-${outcome.objectId}`,
|
||||
@@ -143,5 +147,5 @@ export function rollRunewakerBossLoot(
|
||||
const item = rollSourceSlot(boss, bonusRoll, playerLevel, levelDelta, acquiredAt, random);
|
||||
if (item) drops.push(item);
|
||||
}
|
||||
return allowedLootDrops(drops, playerLevel);
|
||||
return allowedLootDrops(drops, playerLevel, options.ownerCategory);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AbilityEffect } from "./abilityCatalog";
|
||||
import type { CombatSummon } from "./combatStore";
|
||||
import type { CombatPosition } from "./mobRuntimeRegistry";
|
||||
|
||||
/** Select one living, owned, modeled undead minion; hostile actors never qualify. */
|
||||
export function eligibleSacrificeMinion(
|
||||
summons: readonly CombatSummon[],
|
||||
ownerActorId: string,
|
||||
effect: Extract<AbilityEffect, { kind: "sacrifice-minion" }>,
|
||||
origin: CombatPosition,
|
||||
now: number,
|
||||
): CombatSummon | undefined {
|
||||
return summons.find((summon) => summon.ownerActorId === ownerActorId
|
||||
&& summon.health > 0 && summon.expiresAt > now
|
||||
&& effect.creatureIds.includes(summon.creatureId)
|
||||
&& Math.hypot(summon.position[0] - origin[0], summon.position[1] - origin[1],
|
||||
summon.position[2] - origin[2]) <= effect.radius);
|
||||
}
|
||||
@@ -174,6 +174,72 @@ describe("talent runtime modifiers", () => {
|
||||
expect(applyTalentModifierValue(10_000, disciplineModifiers, "cooldown-flat-ms", { abilityName: "Mortal Strike" })).toBe(10_000);
|
||||
});
|
||||
|
||||
it("keeps Blood Frenzy's target-state damage scope broad", () => {
|
||||
const bloodFrenzy = TALENT_NODES.find((node) => node.id === "talent-1664")!;
|
||||
expect(bloodFrenzy.rankDescriptions[1]).toBe(
|
||||
"Increases your melee attack speed by 4%. Rend and Deep Wounds increase all Physical damage caused to that target by 4%. Does not stack with similar effects.",
|
||||
);
|
||||
|
||||
const modifiers = talentModifiersForRanks({ [bloodFrenzy.id]: 2 }, "warrior");
|
||||
expect(modifiers).toContainEqual(expect.objectContaining({
|
||||
kind: "damage-percent",
|
||||
amount: 4,
|
||||
scope: "all Physical damage caused to that target",
|
||||
sourceTalentId: bloodFrenzy.id,
|
||||
rank: 2,
|
||||
}));
|
||||
for (const abilityName of ["Mortal Strike", "Rend", "Deep Wounds"]) {
|
||||
expect(applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName }), abilityName)
|
||||
.toBeCloseTo(104);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Morbidity's Death Coil clauses and Death and Decay cooldown on their named spells", () => {
|
||||
const morbidity = TALENT_NODES.find((node) => node.id === "talent-1933")!;
|
||||
expect(morbidity.name).toBe("Morbidity");
|
||||
expect(morbidity.rankDescriptions[2]).toBe(
|
||||
"Increases the damage and healing of Death Coil by 15% and reduces the cooldown on Death and Decay by 15 sec.",
|
||||
);
|
||||
|
||||
const modifiers = talentModifiersForRanks({ [morbidity.id]: 3 }, "death-knight");
|
||||
expect(modifiers).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "damage-percent", amount: 15 }),
|
||||
expect.objectContaining({ kind: "healing-percent", amount: 15 }),
|
||||
expect.objectContaining({ kind: "cooldown-flat-ms", amount: -15_000, scope: "cooldown on Death and Decay" }),
|
||||
]));
|
||||
|
||||
expect(applyTalentModifierValue(30_000, modifiers, "cooldown-flat-ms", {
|
||||
abilityName: "Death and Decay",
|
||||
})).toBe(15_000);
|
||||
for (const [abilityName, baseCooldownMs] of [
|
||||
["Dark Command", 8_000],
|
||||
["Death Grip", 35_000],
|
||||
["Death Coil", 8_000],
|
||||
] as const) {
|
||||
expect(
|
||||
applyTalentModifierValue(baseCooldownMs, modifiers, "cooldown-flat-ms", { abilityName }),
|
||||
abilityName,
|
||||
).toBe(baseCooldownMs);
|
||||
}
|
||||
|
||||
expect(applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName: "Death Coil" })).toBeCloseTo(115);
|
||||
expect(applyTalentModifierValue(100, modifiers, "healing-percent", { abilityName: "Death Coil" })).toBeCloseTo(115);
|
||||
expect(applyTalentModifierValue(100, modifiers, "damage-percent", { abilityName: "Icy Touch" })).toBe(100);
|
||||
expect(applyTalentModifierValue(100, modifiers, "healing-percent", { abilityName: "Death Strike" })).toBe(100);
|
||||
|
||||
const broadCooldown = parseTalentModifiers("Reduces the cooldown of all your spells by 2 sec.")
|
||||
.map((modifier) => ({
|
||||
...modifier,
|
||||
sourceTalentId: "test:broad-cooldown",
|
||||
sourceTalentName: "Broad Cooldown",
|
||||
rank: 1,
|
||||
description: "Reduces the cooldown of all your spells by 2 sec.",
|
||||
}));
|
||||
for (const abilityName of ["Fireball", "Icy Touch"]) {
|
||||
expect(applyTalentModifierValue(10_000, broadCooldown, "cooldown-flat-ms", { abilityName })).toBe(8_000);
|
||||
}
|
||||
});
|
||||
|
||||
it("scopes WoW Mage Impact's rank-scaled damage bonus to Fire Blast and Meteor", () => {
|
||||
const impact = TALENT_NODES.find((node) => node.id === "talent-30")!;
|
||||
|
||||
|
||||
@@ -223,8 +223,12 @@ export function parseTalentModifiers(description: string): readonly ParsedModifi
|
||||
if (castTime) add({ kind: "cast-time-flat-ms", amount: -Number(castTime[1]) * 1000, unit: "milliseconds", scope: sentence });
|
||||
const cooldown = /\bcasts?\s+reduce(?:s)?\s+(?:the\s+)?cooldown\b/i.test(sentence)
|
||||
? null
|
||||
: sentence.match(/reduces? (?:the )?cooldown (?:of |on )?.*? by (\d+(?:\.\d+)?) sec/i);
|
||||
if (cooldown) add({ kind: "cooldown-flat-ms", amount: -Number(cooldown[1]) * 1000, unit: "milliseconds", scope: sentence });
|
||||
: sentence.match(/\breduces?\s+(?:the\s+)?cooldown(?:\s+(of|on)\s+(.+?))?\s+by\s+(\d+(?:\.\d+)?)\s+secs?\b/i);
|
||||
if (cooldown) {
|
||||
const target = cooldown[2]?.trim();
|
||||
const scope = target ? `cooldown ${cooldown[1]} ${target}` : "cooldown";
|
||||
add({ kind: "cooldown-flat-ms", amount: -Number(cooldown[3]) * 1000, unit: "milliseconds", scope });
|
||||
}
|
||||
}
|
||||
|
||||
return modifiers;
|
||||
@@ -445,6 +449,24 @@ export interface TalentModifierContext {
|
||||
readonly tags?: readonly string[];
|
||||
}
|
||||
|
||||
function namedModifierTarget(scope: string): string | undefined {
|
||||
const scopeWithoutAmount = scope.replace(/\s+by\s+-?\d+(?:\.\d+)?\s*(?:%|secs?)(?:\s*[.!?])?$/i, "");
|
||||
// These are the source tooltip forms used for named abilities (for example,
|
||||
// "cooldown on Death and Decay" and "damage of your Fire Blast"). Other
|
||||
// prepositions can describe a target condition instead, as in "damage
|
||||
// caused to that target", and must leave the modifier broadly applicable.
|
||||
const target = scopeWithoutAmount.match(/\b(?:of|on)\s+(?:your\s+)?([^,.!?]+)$/)?.[1]?.trim();
|
||||
if (!target) return undefined;
|
||||
|
||||
// Generic descriptors describe a category of spells, effects, or targets.
|
||||
// Keep those modifiers broad; remaining words indicate a named ability.
|
||||
const remainingTarget = target.toLowerCase()
|
||||
.replace(/\b(?:a|an|the|your|all|any|each|every|both|other|next|additional|total|maximum|direct|periodic|instant|damaging|healing|helpful|harmful|melee|ranged|magic|physical|arcane|fire|frost|holy|nature|shadow|spell|spells|ability|abilities|attack|attacks|damage|cooldown|cost|cast|casting|time|effect|effects|critical|strike|chance|done|dealt|taken|suffered|received|target|targets|enemy|enemies|ally|allies|friendly|hostile|party|raid|members|you|self|both|and|or|of|on|with|for|to|by)\b/g, " ")
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim();
|
||||
return remainingTarget ? target.toLowerCase() : undefined;
|
||||
}
|
||||
|
||||
function modifierApplies(modifier: TalentModifier, context: TalentModifierContext): boolean {
|
||||
const scope = modifier.scope.toLowerCase();
|
||||
const tags = new Set((context.tags ?? []).map((tag) => tag.toLowerCase()));
|
||||
@@ -469,6 +491,8 @@ function modifierApplies(modifier: TalentModifier, context: TalentModifierContex
|
||||
}
|
||||
if (context.abilityName) {
|
||||
const normalizedName = context.abilityName.toLowerCase();
|
||||
const namedTarget = namedModifierTarget(scope);
|
||||
if (namedTarget && !namedTarget.includes(normalizedName)) return false;
|
||||
const namedScope = /(?:damage|healing|cost|cooldown|cast(?:ing)? time|effect|critical (?:effect |strike )?chance|chance to critically).*?\b(?:by|of|with) your\b/.test(scope);
|
||||
const genericScope = /\byour (?:all |instant |periodic |healing |damage |helpful |harmful )*(?:spells?|abilities?|attacks?)\b/.test(scope)
|
||||
|| /\byour (?:arcane|fire|frost|holy|nature|shadow) spells?\b/.test(scope);
|
||||
|
||||
@@ -1633,6 +1633,14 @@ function classScriptEffects(
|
||||
triggerSpellId: 0,
|
||||
durationMs: 0,
|
||||
}];
|
||||
case "death-knight:Death Pact": return [{
|
||||
kind: "sacrifice-minion",
|
||||
// These are the undead ghoul entries already modeled by Raise Dead and
|
||||
// Army of the Dead. Unknown summon families are never assumed undead.
|
||||
creatureIds: [26125, 24207],
|
||||
radius: rank.effects.find((effect) => effect.effectType === 1)?.radius ?? 0,
|
||||
percentMaxHealth: Math.abs(rank.effects.find((effect) => effect.effectType === 10)?.basePoints ?? 0) / 100,
|
||||
}];
|
||||
case "death-knight:Raise Dead": return [{ kind: "summon", creatureId: 26125, coefficient: 0.45, durationMs: 60_000 }];
|
||||
case "death-knight:Army of the Dead": return Array.from({ length: 8 }, () => ({
|
||||
kind: "summon" as const, creatureId: 24207, coefficient: 0.2, durationMs: 40_000,
|
||||
@@ -1851,6 +1859,11 @@ export function hunterExplosiveTrapDirectScaling(
|
||||
};
|
||||
}
|
||||
|
||||
function learnOnlyRank(rank: Wow335Rank): boolean {
|
||||
const effects = rank.effects.filter((effect) => effect.effectType !== 0);
|
||||
return effects.length > 0 && effects.every((effect) => effect.effectType === 36);
|
||||
}
|
||||
|
||||
function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
|
||||
const effects: AbilityEffect[] = [];
|
||||
for (const [effectIndex, effect] of rank.effects.entries()) {
|
||||
@@ -2150,7 +2163,7 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
||||
}
|
||||
const auraOnlyStance = classId === "death-knight"
|
||||
&& ["Blood Presence", "Frost Presence", "Unholy Presence"].includes(abilityName);
|
||||
if (!auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand")
|
||||
if (!learnOnlyRank(rank) && !auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand")
|
||||
&& !(classId === "mage" && abilityName === "Molten Armor")
|
||||
&& !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
|
||||
if (classId === "death-knight" && abilityName === "Death Coil") {
|
||||
@@ -2167,6 +2180,7 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
||||
.filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported")
|
||||
.filter((effect) => !(
|
||||
(classId === "paladin" && abilityName === "Redemption" && effect.kind === "resurrection")
|
||||
|| (classId === "death-knight" && abilityName === "Death Pact" && ["execute", "heal"].includes(effect.kind))
|
||||
|| (classId === "paladin" && abilityName === "Divine Storm"
|
||||
&& (effect.kind === "damage" || effect.kind === "heal"))
|
||||
|| (classId === "paladin" && abilityName === "Holy Shield" && effect.kind === "damage")
|
||||
@@ -2523,8 +2537,9 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
||||
const effectiveSourceUnlockLevel = classId === "warrior" && chain.name === "Sweeping Strikes"
|
||||
? Math.max(1, firstRaw.sourceLevel || firstRaw.level)
|
||||
: chain.sourceUnlockLevel;
|
||||
const target = abilityNameTarget(classId, chain.name) ?? targetForRank(firstRaw, first.effects);
|
||||
const radius = Math.max(
|
||||
const target = first.effects.some((effect) => effect.kind === "sacrifice-minion")
|
||||
? "self" : abilityNameTarget(classId, chain.name) ?? targetForRank(firstRaw, first.effects);
|
||||
const radius = first.effects.some((effect) => effect.kind === "sacrifice-minion") ? 0 : Math.max(
|
||||
abilityNameRadius(chain.name),
|
||||
...firstRaw.effects.map((effect) => effect.radius),
|
||||
);
|
||||
@@ -2579,7 +2594,8 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
||||
gcdMs: first.gcdMs,
|
||||
cost: first.cost,
|
||||
effects: first.effects,
|
||||
passive: chain.passive,
|
||||
passive: chain.passive || (Boolean(chain.talentNodeId) && castRanks.every((rank) => learnOnlyRank(rank) && rank.castTimeMs === 0
|
||||
&& rank.cooldownMs === 0 && rank.powerCost === 0 && rank.powerCostPercentage === 0)),
|
||||
source: "wow335-progression",
|
||||
// Some stock WotLK active talents remain in Spell.dbc after Ascension's
|
||||
// patched Talent.dbc removes their nodes (Devastate, Shadowstep, Lava
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { abilitiesForCharacter, abilityAtLevel, type AbilityEffect } from "./abilityCatalog";
|
||||
import { PLAYER_AGGRO_ID } from "./aggro";
|
||||
import { installCombatClock } from "./combatClock";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { advanceDungeonPartyCombat, partyAbilityTargetIsValid, resetPartyRuntimeClock } from "./partyRuntime";
|
||||
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
|
||||
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
|
||||
const PACT = "wow335-death-knight-death-pact";
|
||||
const RAISE = "wow335-death-knight-raise-dead";
|
||||
const ability = (id: string) => abilityAtLevel(abilitiesForCharacter("death-knight", null).find(a => a.id === id)!, 80);
|
||||
let restoreClock: (() => void) | undefined;
|
||||
|
||||
function hostile(id: string, boss = false) {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0, boss });
|
||||
setMobPosition(id, [2, 0, 0]);
|
||||
useCombatStore.getState().selectMob(id);
|
||||
}
|
||||
|
||||
function summon(owner = PLAYER_AGGRO_ID, position: readonly [number, number, number] = [0, 0, 0], creatureId = 26125) {
|
||||
const source = ability(RAISE);
|
||||
const effect = source.effects.find(e => e.kind === "summon") as Extract<AbilityEffect, {kind: "summon"}>;
|
||||
return useCombatStore.getState().summonActor(owner, source, { ...effect, creatureId }, position, null, 1_000)!;
|
||||
}
|
||||
|
||||
describe("Death Pact source semantics and learned Blood Gorged", () => {
|
||||
beforeEach(() => {
|
||||
restoreClock = installCombatClock(() => 1_000);
|
||||
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
|
||||
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80 });
|
||||
usePartyStore.setState({ members: [], command: "attack" });
|
||||
useCombatStore.setState({ health: 200, maxHealth: 1_000, resource: 100,
|
||||
resourcePools: { "runic-power": 100 }, gearStats: { ...EMPTY_ITEM_STATS, criticalStrikeRating: 100_000 } });
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
});
|
||||
afterEach(() => restoreClock?.());
|
||||
|
||||
it("does not spend resources, cooldown or GCD without a living owned minion", () => {
|
||||
hostile("trash"); hostile("boss", true);
|
||||
const result = useCombatStore.getState().castAbility(PACT, undefined, 2_000);
|
||||
expect(result).toMatchObject({ ok: false, reason: "condition-not-met", damage: 0, healing: 0 });
|
||||
const s = useCombatStore.getState();
|
||||
expect(s.resource).toBe(100); expect(s.cooldowns[PACT]).toBeUndefined();
|
||||
expect(s.globalCooldownEndsAt).toBe(0);
|
||||
expect(s.mobs.trash.health).toBe(100_000); expect(s.mobs.boss.health).toBe(100_000);
|
||||
});
|
||||
|
||||
it("consumes one actual Raise Dead minion, heals only its caster for 40%, and never crits", () => {
|
||||
hostile("trash"); hostile("boss", true);
|
||||
expect(useCombatStore.getState().castAbility(RAISE, undefined, 1_000).ok).toBe(true);
|
||||
const first = useCombatStore.getState().summons.find(s => s.creatureId === 26125)!;
|
||||
expect(first).toBeDefined();
|
||||
const other = summon();
|
||||
const before = useCombatStore.getState();
|
||||
const result = before.castAbility(PACT, undefined, 3_000);
|
||||
expect(result).toMatchObject({ ok: true, damage: 0, healing: 400 });
|
||||
const s = useCombatStore.getState();
|
||||
expect(s.health).toBe(600); expect(s.resource).toBe(60);
|
||||
expect(s.cooldowns[PACT]).toBe(123_000); expect(s.globalCooldownEndsAt).toBe(4_500);
|
||||
expect(s.summons.find(x => x.id === first.id)?.health).toBe(0);
|
||||
expect(s.actors[first.id]).toMatchObject({ health: 0, alive: false });
|
||||
expect(s.summons.find(x => x.id === other)?.health).toBeGreaterThan(0);
|
||||
expect(s.mobs.trash.health).toBe(100_000); expect(s.mobs.boss.health).toBe(100_000);
|
||||
const events = s.combatEvents.filter(e => e.abilityId === PACT);
|
||||
expect(events.filter(e => e.kind === "damage")).toHaveLength(0);
|
||||
expect(events.filter(e => e.kind === "death")).toHaveLength(1);
|
||||
expect(events.filter(e => e.kind === "healing")).toEqual([expect.objectContaining({
|
||||
targetActorId: PLAYER_AGGRO_ID, rawAmount: 400, effectiveAmount: 400, critical: false,
|
||||
})]);
|
||||
});
|
||||
|
||||
it.each(["dead", "expired", "distant", "living-family"])("rejects %s minions and does not heal", mode => {
|
||||
const id = summon(PLAYER_AGGRO_ID, mode === "distant" ? [101, 0, 0] : [0, 0, 0], mode === "living-family" ? 29264 : 26125);
|
||||
useCombatStore.setState(s => ({ summons: s.summons.map(x => x.id === id
|
||||
? { ...x, ...(mode === "dead" ? {health: 0} : {}), ...(mode === "expired" ? {expiresAt: 2_000} : {}) } : x) }));
|
||||
expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000)).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
expect(useCombatStore.getState().health).toBe(200);
|
||||
});
|
||||
|
||||
it("does not consume another caster's minion; companion automatic use heals only itself", () => {
|
||||
const base = createConfiguredPartyMember({ name: "DK", classId: "death-knight", specialization: "Blood",
|
||||
specializationId: "death-knight:blood", talentTreeId: "death-knight-blood", role: "damage" }, 80, 0, "pact-party");
|
||||
const member = { ...base, health: 100, maxHealth: 1_000, resource: 100,
|
||||
resourcePools: {"runic-power": 100}, gearStats: EMPTY_ITEM_STATS, nextActionAt: 0,
|
||||
talentRanks: {}, cooldowns: Object.fromEntries(abilitiesForCharacter("death-knight", null)
|
||||
.filter(a => a.id !== PACT).map(a => [a.id, 20_000])) };
|
||||
usePartyStore.setState({ members: [member], command: "attack" });
|
||||
registerPartyRuntimePosition(member.id, [0, 0, 0]);
|
||||
useCombatStore.getState().synchronizeActors();
|
||||
const id = summon(member.id);
|
||||
expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000)).toMatchObject({ ok: false, reason: "condition-not-met" });
|
||||
expect(partyAbilityTargetIsValid(member, ability(PACT), member.id, undefined, 2_000)).toBe(true);
|
||||
hostile("party-trash");
|
||||
useCombatStore.setState(s => ({ mobs: { ...s.mobs, "party-trash": { ...s.mobs["party-trash"], engaged: true } } }));
|
||||
advanceDungeonPartyCombat(2_000);
|
||||
|
||||
const s = useCombatStore.getState();
|
||||
expect(s.summons.find(x => x.id === id)?.health).toBe(0);
|
||||
expect(s.health).toBe(200);
|
||||
expect(usePartyStore.getState().members[0].health).toBe(500);
|
||||
expect(s.combatEvents.filter(e => e.kind === "healing" && e.abilityId === PACT)).toEqual([
|
||||
expect.objectContaining({ sourceActorId: member.id, targetActorId: member.id, rawAmount: 400, critical: false }),
|
||||
]);
|
||||
expect(partyAbilityTargetIsValid(member, ability(PACT), member.id, undefined, 2_001)).toBe(false);
|
||||
});
|
||||
|
||||
it("caps overhealing and accounts for it once", () => {
|
||||
summon(); useCombatStore.setState({ health: 950 });
|
||||
expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000).healing).toBe(50);
|
||||
expect(useCombatStore.getState().combatEvents.find(e => e.kind === "healing" && e.abilityId === PACT))
|
||||
.toMatchObject({ rawAmount: 400, effectiveAmount: 50, overheal: 350, critical: false });
|
||||
});
|
||||
|
||||
it.each([1, 2, 3, 4, 5])("keeps Blood Gorged rank %s passive without an invented direct attack", rank => {
|
||||
const source = ability("wow335-death-knight-blood-gorged");
|
||||
expect(source.passive).toBe(true);
|
||||
expect(source.ranks?.[rank - 1].effects.some(e => ["damage", "heal", "execute"].includes(e.kind))).toBe(false);
|
||||
useCombatStore.setState({ talentRanks: { "talent-2034": rank } });
|
||||
expect(useCombatStore.getState().castAbility(source.id, undefined, 2_000))
|
||||
.toMatchObject({ ok: false, reason: "passive", damage: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const METADATA_EXCLUDES = ["**/._*", "**/._*/**"];
|
||||
const SENTINELS = ["src/metadata-discovery-sentinel.test.ts", "src/ordinary._fragment.test.ts"];
|
||||
const SENTINEL = SENTINELS[0];
|
||||
const METADATA_FILE = "src/._appledouble-file.test.ts";
|
||||
const METADATA_NESTED_FILE = "src/._appledouble-directory/nested-metadata.test.ts";
|
||||
|
||||
function sha256(value) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function sha256File(filePath) {
|
||||
assertRegularNoSymlink(filePath);
|
||||
return sha256(readFileSync(filePath));
|
||||
}
|
||||
|
||||
function assertRegularNoSymlink(filePath) {
|
||||
const stat = lstatSync(filePath);
|
||||
assert.equal(stat.isSymbolicLink(), false, `symbolic link is not accepted: ${filePath}`);
|
||||
assert.equal(stat.isFile(), true, `expected a regular file: ${filePath}`);
|
||||
}
|
||||
|
||||
function assertNoSymlinkComponents(targetPath) {
|
||||
const absolute = path.resolve(targetPath);
|
||||
const parsed = path.parse(absolute);
|
||||
let current = parsed.root;
|
||||
for (const component of absolute.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
|
||||
current = path.join(current, component);
|
||||
try {
|
||||
const stat = lstatSync(current);
|
||||
assert.equal(stat.isSymbolicLink(), false, `symbolic path component is not accepted: ${current}`);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") break;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hasPathPrefix(parent, candidate) {
|
||||
const relative = path.relative(parent, candidate);
|
||||
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function canonicalizeProspectivePath(targetPath) {
|
||||
let current = path.resolve(targetPath);
|
||||
const suffix = [];
|
||||
while (true) {
|
||||
try {
|
||||
const canonicalAncestor = realpathSync(current);
|
||||
return path.resolve(canonicalAncestor, ...suffix.reverse());
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
const parent = path.dirname(current);
|
||||
assert.notEqual(parent, current, `no existing ancestor for evidence path: ${targetPath}`);
|
||||
suffix.push(path.basename(current));
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoAppleDoublePathComponents(targetPath) {
|
||||
const absolute = path.resolve(targetPath);
|
||||
const parsed = path.parse(absolute);
|
||||
const components = absolute.slice(parsed.root.length).split(path.sep).filter(Boolean);
|
||||
for (const component of components) {
|
||||
assert.equal(component.startsWith("._"), false, `AppleDouble path component is not accepted: ${component}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertFreshEvidenceDirectory(targetPath) {
|
||||
assert.equal(path.isAbsolute(targetPath), true, "--evidence-dir must be absolute");
|
||||
const absolute = path.resolve(targetPath);
|
||||
assert.equal(absolute, targetPath, "--evidence-dir must be normalized");
|
||||
assertNoAppleDoublePathComponents(absolute);
|
||||
assertNoSymlinkComponents(absolute);
|
||||
const canonicalProjectRoot = realpathSync(PROJECT_ROOT);
|
||||
const canonicalEvidence = canonicalizeProspectivePath(absolute);
|
||||
assert.equal(hasPathPrefix(canonicalProjectRoot, canonicalEvidence) || hasPathPrefix(canonicalEvidence, canonicalProjectRoot), false,
|
||||
"evidence directory must not overlap the checkout");
|
||||
try {
|
||||
lstatSync(absolute);
|
||||
assert.fail(`evidence directory already exists; use a fresh path: ${absolute}`);
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
mkdirSync(path.dirname(absolute), { recursive: true });
|
||||
mkdirSync(absolute, { recursive: false });
|
||||
return absolute;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const token = argv[index];
|
||||
if (token !== "--evidence-dir" || !argv[index + 1]) {
|
||||
throw new Error("usage: verify-appledouble-metadata-discovery.mjs --evidence-dir ABSOLUTE_FRESH_PATH");
|
||||
}
|
||||
if (args.evidenceDir) throw new Error("--evidence-dir may be provided once");
|
||||
args.evidenceDir = argv[index + 1];
|
||||
index += 1;
|
||||
}
|
||||
if (!args.evidenceDir || !path.isAbsolute(args.evidenceDir)) {
|
||||
throw new Error("--evidence-dir must be an absolute path");
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function runNode(args, cwd, stdoutPath, stderrPath) {
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
env: { ...process.env, CI: "1" },
|
||||
});
|
||||
writeFileSync(stdoutPath, result.stdout ?? "", "utf8");
|
||||
writeFileSync(stderrPath, result.stderr ?? "", "utf8");
|
||||
if (result.error) throw result.error;
|
||||
return {
|
||||
argv: [process.execPath, ...args],
|
||||
cwd,
|
||||
exitCode: result.status,
|
||||
signal: result.signal,
|
||||
stdout: { path: stdoutPath, sha256: sha256File(stdoutPath) },
|
||||
stderr: { path: stderrPath, sha256: sha256File(stderrPath) },
|
||||
error: result.error?.message ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function readGitHead() {
|
||||
const result = spawnSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: PROJECT_ROOT,
|
||||
encoding: "utf8",
|
||||
});
|
||||
return result.status === 0 ? result.stdout.trim() : null;
|
||||
}
|
||||
|
||||
function generatedTest(importPath, name, body) {
|
||||
return `import { expect, it } from ${JSON.stringify(importPath)};\n\nit(${JSON.stringify(name)}, () => {\n${body}\n});\n`;
|
||||
}
|
||||
|
||||
function readVitestFiles(jsonPath, fixtureRoot) {
|
||||
const report = JSON.parse(readFileSync(jsonPath, "utf8"));
|
||||
const testResults = report.testResults;
|
||||
assert.ok(Array.isArray(testResults), "Vitest JSON reporter must provide testResults");
|
||||
return testResults.map((result) => path.relative(fixtureRoot, result.name).split(path.sep).join("/")).sort();
|
||||
}
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
let evidenceRootForFailure = null;
|
||||
|
||||
async function main() {
|
||||
const { evidenceDir } = parseArgs(process.argv.slice(2));
|
||||
const absoluteEvidence = assertFreshEvidenceDirectory(evidenceDir);
|
||||
evidenceRootForFailure = absoluteEvidence;
|
||||
|
||||
const reportPath = path.join(absoluteEvidence, "report.json");
|
||||
const R = createRequire(path.join(PROJECT_ROOT, "package.json"));
|
||||
const vitestRunner = R.resolve("vitest/vitest.mjs");
|
||||
const vitestEntry = R.resolve("vitest/dist/index.js");
|
||||
const vitestConfigEntry = R.resolve("vitest/dist/config.js");
|
||||
const viteEntry = R.resolve("vite");
|
||||
const tsEntry = R.resolve("typescript");
|
||||
const vitestPackage = JSON.parse(readFileSync(path.join(PROJECT_ROOT, "node_modules", "vitest", "package.json"), "utf8"));
|
||||
const vitePackage = JSON.parse(readFileSync(path.join(PROJECT_ROOT, "node_modules", "vite", "package.json"), "utf8"));
|
||||
const tsPackage = JSON.parse(readFileSync(path.join(PROJECT_ROOT, "node_modules", "typescript", "package.json"), "utf8"));
|
||||
const configInfo = {};
|
||||
for (const name of ["vite.config.ts", "tsconfig.app.json"]) {
|
||||
const actualPath = path.join(PROJECT_ROOT, name);
|
||||
const actualHash = sha256File(actualPath);
|
||||
configInfo[name] = { path: actualPath, sha256: actualHash };
|
||||
}
|
||||
|
||||
const vite = await import(pathToFileURL(viteEntry).href);
|
||||
const { configDefaults } = await import(pathToFileURL(vitestConfigEntry).href);
|
||||
const loaded = await vite.loadConfigFromFile({ command: "serve", mode: "test" }, path.join(PROJECT_ROOT, "vite.config.ts"), PROJECT_ROOT);
|
||||
assert.ok(loaded?.config, "Vite must load the actual project config");
|
||||
const testConfig = loaded.config.test;
|
||||
assert.deepEqual(testConfig.include, ["src/**/*.test.ts"], "candidate must preserve the normal Vitest include");
|
||||
assert.ok(Array.isArray(testConfig.exclude), "project Vitest config must define exclude patterns");
|
||||
for (const defaultExclude of configDefaults.exclude) {
|
||||
assert.ok(testConfig.exclude.includes(defaultExclude), `Vitest built-in exclusion was dropped: ${defaultExclude}`);
|
||||
}
|
||||
for (const metadataExclude of METADATA_EXCLUDES) {
|
||||
assert.ok(testConfig.exclude.includes(metadataExclude), `metadata exclusion is missing: ${metadataExclude}`);
|
||||
}
|
||||
const baselineTestConfig = {
|
||||
...testConfig,
|
||||
exclude: testConfig.exclude.filter((pattern) => !METADATA_EXCLUDES.includes(pattern)),
|
||||
};
|
||||
|
||||
const fixtureRoot = path.join(absoluteEvidence, "fixture-project");
|
||||
const srcRoot = path.join(fixtureRoot, "src");
|
||||
mkdirSync(srcRoot, { recursive: true });
|
||||
writeFileSync(path.join(srcRoot, "._appledouble-file.test.ts"),
|
||||
generatedTest(vitestEntry, "AppleDouble file fixture is discoverable without the scoped exclusion", " expect(true).toBe(true);\n"), "utf8");
|
||||
mkdirSync(path.join(srcRoot, "._appledouble-directory"), { recursive: true });
|
||||
writeFileSync(path.join(srcRoot, "._appledouble-directory", "nested-metadata.test.ts"),
|
||||
generatedTest(vitestEntry, "AppleDouble nested-directory fixture is discoverable without the scoped exclusion", " expect(true).toBe(true);\n"), "utf8");
|
||||
writeFileSync(path.join(srcRoot, "metadata-discovery-sentinel.test.ts"),
|
||||
generatedTest(vitestEntry, "ordinary source sentinel runs", " expect(2 + 2).toBe(4);\n"), "utf8");
|
||||
writeFileSync(path.join(srcRoot, "ordinary._fragment.test.ts"),
|
||||
generatedTest(vitestEntry, "ordinary filename containing dot underscore remains included", " expect(" +
|
||||
"'ordinary._fragment.test.ts'.startsWith('._')).toBe(false);\n"), "utf8");
|
||||
// This control is derived from the actual loaded config and removes only the two issue filters.
|
||||
const baselineConfig = path.join(absoluteEvidence, "baseline-vitest.config.mjs");
|
||||
writeFileSync(baselineConfig, `export default ${JSON.stringify({ test: baselineTestConfig }, null, 2)};\n`, "utf8");
|
||||
|
||||
const baselineJson = path.join(absoluteEvidence, "vitest-baseline-results.json");
|
||||
const baseline = runNode([vitestRunner, "run", "--root", fixtureRoot, "--config", baselineConfig,
|
||||
"--reporter=json", `--outputFile=${baselineJson}`, "--maxWorkers=1"], PROJECT_ROOT,
|
||||
path.join(absoluteEvidence, "vitest-baseline.stdout.log"), path.join(absoluteEvidence, "vitest-baseline.stderr.log"));
|
||||
const baselineFiles = readVitestFiles(baselineJson, fixtureRoot);
|
||||
for (const sentinel of SENTINELS) {
|
||||
assert.ok(baselineFiles.includes(sentinel), `baseline Vitest control must execute ordinary file: ${sentinel}`);
|
||||
}
|
||||
assert.ok(baselineFiles.includes(METADATA_FILE), "baseline control must discover the AppleDouble file fixture");
|
||||
assert.ok(baselineFiles.includes(METADATA_NESTED_FILE), "baseline control must discover the nested AppleDouble fixture");
|
||||
const baselineJsonResult = JSON.parse(readFileSync(baselineJson, "utf8"));
|
||||
const baselineFailures = baselineJsonResult.testResults
|
||||
.filter((result) => result.status === "failed")
|
||||
.map((result) => ({
|
||||
path: path.relative(fixtureRoot, result.name).split(path.sep).join("/"),
|
||||
message: result.message,
|
||||
}));
|
||||
assert.ok(baseline.exitCode === 0 || baseline.exitCode === 1, "baseline must exit normally, not crash");
|
||||
for (const failure of baselineFailures) {
|
||||
assert.ok(path.posix.basename(failure.path).startsWith("._"),
|
||||
`baseline may fail only while transforming an AppleDouble sidecar: ${failure.path}`);
|
||||
assert.ok(failure.message.includes("Unexpected") && failure.message.includes("\\x00"),
|
||||
`baseline AppleDouble transform failure should identify binary metadata: ${failure.path}`);
|
||||
}
|
||||
|
||||
const candidateJson = path.join(absoluteEvidence, "vitest-candidate-results.json");
|
||||
const candidate = runNode([vitestRunner, "run", "--root", fixtureRoot,
|
||||
"--config", path.join(PROJECT_ROOT, "vite.config.ts"), "--mode", "test", "--reporter=json",
|
||||
`--outputFile=${candidateJson}`, "--maxWorkers=1"], PROJECT_ROOT,
|
||||
path.join(absoluteEvidence, "vitest-candidate.stdout.log"), path.join(absoluteEvidence, "vitest-candidate.stderr.log"));
|
||||
assert.equal(candidate.exitCode, 0, "candidate Vitest discovery must pass");
|
||||
const candidateFiles = readVitestFiles(candidateJson, fixtureRoot);
|
||||
assert.deepEqual(candidateFiles, [...SENTINELS].sort(), "candidate discovery must keep ordinary names and exclude dot-prefix metadata");
|
||||
|
||||
const ts = R("typescript");
|
||||
const tsconfigSource = path.join(PROJECT_ROOT, "tsconfig.app.json");
|
||||
const tsconfigPath = path.join(fixtureRoot, "tsconfig.app.json");
|
||||
const tsconfigBytes = readFileSync(tsconfigSource);
|
||||
writeFileSync(tsconfigPath, tsconfigBytes);
|
||||
const tsconfigJson = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
|
||||
assert.equal(tsconfigJson.error, undefined, "TypeScript must read the copied app config");
|
||||
const parsedTsconfig = ts.parseJsonConfigFileContent(tsconfigJson.config, ts.sys, fixtureRoot, undefined, tsconfigPath);
|
||||
assert.deepEqual(parsedTsconfig.errors, [], "TypeScript config must parse without diagnostics");
|
||||
assert.deepEqual(tsconfigJson.config.include, ["src"], "TypeScript include must retain normal src scope");
|
||||
assert.deepEqual(tsconfigJson.config.compilerOptions.types, ["vite/client", "vitest/globals"],
|
||||
"TypeScript configured test/browser globals must remain unchanged");
|
||||
const tsFiles = parsedTsconfig.fileNames.map((filePath) => path.relative(fixtureRoot, filePath).split(path.sep).join("/")).sort();
|
||||
assert.deepEqual(tsFiles, [...SENTINELS].sort(), "TypeScript must keep ordinary names and exclude dot-prefix metadata");
|
||||
const expectedPackageExcludes = ["node_modules", "bower_components", "jspm_packages"];
|
||||
for (const exclude of expectedPackageExcludes) {
|
||||
assert.ok(tsconfigJson.config.exclude.includes(exclude), `TypeScript package exclusion was dropped: ${exclude}`);
|
||||
}
|
||||
for (const exclude of METADATA_EXCLUDES) {
|
||||
assert.ok(tsconfigJson.config.exclude.includes(exclude), `TypeScript metadata exclusion is missing: ${exclude}`);
|
||||
}
|
||||
|
||||
const tscPath = path.resolve(path.dirname(tsEntry), "../bin/tsc");
|
||||
const baselineTsconfigPath = path.join(fixtureRoot, "tsconfig.app.without-appledouble-excludes.json");
|
||||
const baselineTsconfigObject = JSON.parse(tsconfigBytes.toString("utf8"));
|
||||
baselineTsconfigObject.exclude = baselineTsconfigObject.exclude.filter((pattern) => !METADATA_EXCLUDES.includes(pattern));
|
||||
writeFileSync(baselineTsconfigPath, `${JSON.stringify(baselineTsconfigObject, null, 2)}\n`, "utf8");
|
||||
const baselineTsconfigJson = ts.readConfigFile(baselineTsconfigPath, ts.sys.readFile);
|
||||
assert.equal(baselineTsconfigJson.error, undefined, "TypeScript must read the filtered-control app config");
|
||||
const baselineParsedTsconfig = ts.parseJsonConfigFileContent(
|
||||
baselineTsconfigJson.config, ts.sys, fixtureRoot, undefined, baselineTsconfigPath,
|
||||
);
|
||||
assert.deepEqual(baselineParsedTsconfig.errors, [], "filtered-control TypeScript config must parse without diagnostics");
|
||||
const baselineTsFiles = baselineParsedTsconfig.fileNames
|
||||
.map((filePath) => path.relative(fixtureRoot, filePath).split(path.sep).join("/")).sort();
|
||||
assert.deepEqual(baselineTsFiles, [...SENTINELS].sort(),
|
||||
"TypeScript 5.8 should continue to skip dot paths even without the explicit filters");
|
||||
const baselineTsc = runNode([tscPath, "--showConfig", "--project", baselineTsconfigPath], PROJECT_ROOT,
|
||||
path.join(absoluteEvidence, "tsc-control-show-config.stdout.json"), path.join(absoluteEvidence, "tsc-control-show-config.stderr.log"));
|
||||
assert.equal(baselineTsc.exitCode, 0, "the filtered-control TypeScript CLI --showConfig must accept the fixture config");
|
||||
const baselineTscJson = JSON.parse(readFileSync(path.join(absoluteEvidence, "tsc-control-show-config.stdout.json"), "utf8"));
|
||||
const baselineTscFiles = baselineTscJson.files
|
||||
.map((filePath) => path.relative(fixtureRoot, path.resolve(fixtureRoot, filePath)).split(path.sep).join("/")).sort();
|
||||
assert.deepEqual(baselineTscFiles, [...SENTINELS].sort());
|
||||
assert.deepEqual(baselineTscFiles, baselineTsFiles,
|
||||
"TypeScript compiler API and CLI must agree on filtered-control src inputs");
|
||||
const tsc = runNode([tscPath, "--showConfig", "--project", tsconfigPath], PROJECT_ROOT,
|
||||
path.join(absoluteEvidence, "tsc-show-config.stdout.json"), path.join(absoluteEvidence, "tsc-show-config.stderr.log"));
|
||||
assert.equal(tsc.exitCode, 0, "the actual TypeScript CLI --showConfig must accept the fixture config");
|
||||
const tscJson = JSON.parse(readFileSync(path.join(absoluteEvidence, "tsc-show-config.stdout.json"), "utf8"));
|
||||
const tscFiles = tscJson.files.map((filePath) => path.relative(fixtureRoot, path.resolve(fixtureRoot, filePath)).split(path.sep).join("/")).sort();
|
||||
assert.deepEqual(tscFiles, [...SENTINELS].sort(), "the TypeScript CLI must resolve the same two ordinary inputs");
|
||||
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
passed: true,
|
||||
issue: 11,
|
||||
node: { executable: process.execPath, version: process.version },
|
||||
invocationCwd: process.cwd(),
|
||||
tool: { path: fileURLToPath(import.meta.url), sha256: sha256File(fileURLToPath(import.meta.url)) },
|
||||
project: {
|
||||
root: PROJECT_ROOT,
|
||||
gitHead: readGitHead(),
|
||||
configHashes: configInfo,
|
||||
},
|
||||
packageVersions: { vitest: vitestPackage.version, vite: vitePackage.version, typescript: tsPackage.version },
|
||||
fixtures: {
|
||||
root: fixtureRoot,
|
||||
ordinarySentinel: { path: SENTINEL, sha256: sha256File(path.join(fixtureRoot, SENTINEL)) },
|
||||
ordinaryDotUnderscoreFragment: {
|
||||
path: SENTINELS[1],
|
||||
sha256: sha256File(path.join(fixtureRoot, SENTINELS[1])),
|
||||
reasonIncluded: "the basename contains ._ but does not begin with the AppleDouble component prefix ._",
|
||||
},
|
||||
metadataFile: { path: METADATA_FILE, sha256: sha256File(path.join(fixtureRoot, METADATA_FILE)) },
|
||||
metadataNestedFile: { path: METADATA_NESTED_FILE, sha256: sha256File(path.join(fixtureRoot, METADATA_NESTED_FILE)) },
|
||||
},
|
||||
vitest: {
|
||||
unfilteredControl: {
|
||||
description: "actual loaded test config with only the two issue-owned excludes removed; all other settings and Vitest built-in defaults are retained",
|
||||
configSha256: sha256File(baselineConfig),
|
||||
removedExcludes: METADATA_EXCLUDES,
|
||||
command: baseline,
|
||||
jsonReport: { path: baselineJson, sha256: sha256File(baselineJson) },
|
||||
exitCode: baseline.exitCode,
|
||||
failures: baselineFailures,
|
||||
discoveredFiles: baselineFiles,
|
||||
ordinarySentinelsExecuted: SENTINELS.every((name) => baselineFiles.includes(name)),
|
||||
metadataFixturesDiscovered: [METADATA_FILE, METADATA_NESTED_FILE].every((name) => baselineFiles.includes(name)),
|
||||
completedNormally: baseline.exitCode === 0 || baseline.exitCode === 1,
|
||||
},
|
||||
candidate: {
|
||||
command: candidate,
|
||||
jsonReport: { path: candidateJson, sha256: sha256File(candidateJson) },
|
||||
exitCode: candidate.exitCode,
|
||||
discoveredFiles: candidateFiles,
|
||||
expectedOrdinaryFiles: [...SENTINELS].sort(),
|
||||
include: testConfig.include,
|
||||
builtinExcludesPreserved: configDefaults.exclude.every((name) => testConfig.exclude.includes(name)),
|
||||
metadataExcludes: METADATA_EXCLUDES,
|
||||
},
|
||||
},
|
||||
typescript: {
|
||||
compilerApi: {
|
||||
parsedConfigPath: tsconfigPath,
|
||||
parsedConfigSha256: sha256(tsconfigBytes),
|
||||
diagnostics: parsedTsconfig.errors.length,
|
||||
},
|
||||
baseline: {
|
||||
configPath: baselineTsconfigPath,
|
||||
removedExcludes: METADATA_EXCLUDES,
|
||||
parsedFiles: baselineTsFiles,
|
||||
metadataFileIncludedBeforeExplicitExcludes: baselineTsFiles.includes(METADATA_FILE),
|
||||
nestedMetadataIncludedBeforeExplicitExcludes: baselineTsFiles.includes(METADATA_NESTED_FILE),
|
||||
note: "TypeScript 5.8 already skips dot-prefixed paths during include globbing; the candidate also retains explicit excludes for portable input hygiene.",
|
||||
cliCommand: baselineTsc,
|
||||
cliJsonSha256: sha256File(path.join(absoluteEvidence, "tsc-control-show-config.stdout.json")),
|
||||
},
|
||||
cliCommand: tsc,
|
||||
cliJsonSha256: sha256File(path.join(absoluteEvidence, "tsc-show-config.stdout.json")),
|
||||
parsedFiles: tsFiles,
|
||||
cliParsedFiles: tscFiles,
|
||||
include: tsconfigJson.config.include,
|
||||
types: tsconfigJson.config.compilerOptions.types,
|
||||
packageExcludesPreserved: expectedPackageExcludes.every((name) => tsconfigJson.config.exclude.includes(name)),
|
||||
metadataExcludes: METADATA_EXCLUDES,
|
||||
copiedConfigSha256: sha256(tsconfigBytes),
|
||||
},
|
||||
logs: ["vitest-baseline.stdout.log", "vitest-baseline.stderr.log", "vitest-candidate.stdout.log",
|
||||
"vitest-candidate.stderr.log", "tsc-control-show-config.stderr.log", "tsc-show-config.stderr.log"].map((name) => ({
|
||||
path: path.join(absoluteEvidence, name),
|
||||
sha256: sha256File(path.join(absoluteEvidence, name)),
|
||||
})),
|
||||
};
|
||||
writeJson(reportPath, report);
|
||||
process.stdout.write(`${reportPath}\n`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const safe = { passed: false, error: `${error.name}: ${error.message}` };
|
||||
if (evidenceRootForFailure) {
|
||||
try { writeJson(path.join(evidenceRootForFailure, "failure.json"), safe); } catch { /* preserve original failure */ }
|
||||
}
|
||||
process.stderr.write(`${safe.error}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+8
-1
@@ -29,5 +29,12 @@
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client", "vitest/globals"]
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"bower_components",
|
||||
"jspm_packages",
|
||||
"**/._*",
|
||||
"**/._*/**"
|
||||
]
|
||||
}
|
||||
+6
-1
@@ -1,6 +1,6 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { configDefaults, defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
// @ts-expect-error Shared Node HTTP middleware also serves production bundles.
|
||||
import { createBundledContentHandler } from "./server/bundled-content.mjs";
|
||||
@@ -119,6 +119,11 @@ export default defineConfig(({ mode }) => {
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
exclude: [
|
||||
...configDefaults.exclude,
|
||||
"**/._*",
|
||||
"**/._*/**",
|
||||
],
|
||||
maxWorkers: 4,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user