forked from phenom/healer-man
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f8cb8a510 | ||
|
|
9be6f54c52 | ||
|
|
6a716fb8ca | ||
|
|
b3e0445e9c | ||
|
|
72a67ac1e3 | ||
|
|
89b6f4f79d | ||
|
|
3687c423e2 | ||
|
|
a9322a3897 | ||
|
|
768a3275ea | ||
|
|
812162320d | ||
|
|
e567910c04 | ||
|
|
ab37006932 | ||
|
|
5385b14e3a |
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,11 @@
|
||||
# Account Boss Card checkpoints
|
||||
|
||||
`PATCH /api/cloud-save` adds an account-scoped checkpoint for Boss Cards without sending a replacement cloud document. The server derives the owner from the authenticated bearer session; request fields cannot choose an account. The body is `{ "bossCards": <raw collection>, "expectedRevision": <nonnegative safe integer> }`.
|
||||
|
||||
The server serializes the compare and write in a SQLite `BEGIN IMMEDIATE` transaction. A missing save has revision `0`. A matching request merges the submitted collection with that account’s saved cards, changes only `data.bossCards`, increments the cloud revision, and returns `{ revision, updatedAt, data }`. A stale revision returns HTTP `409` with `code: "revision_conflict"` and writes nothing. The client should reload the current save before retrying. Missing collections and invalid revisions return a client error; malformed collection values normalize to empty under the source policy without removing existing cards; an unauthenticated request is rejected before the body is used.
|
||||
|
||||
Normalization and merge behavior match `src/game/bossCards.ts`: the allowed rarities are Normal, Rare, Epic, and Legendary; positive finite counts are floored and bounded by `Number.MAX_SAFE_INTEGER`; invalid/unknown entries are dropped; repeated account copies keep maximum counts. Numeric `time:revision` claim IDs keep the later time and then higher revision; other claim strings use lexical maximum. Map-like output uses null-prototype count/claim records so names such as `__proto__` remain ordinary data keys.
|
||||
|
||||
Legacy `PUT /api/cloud-save` remains a full replacement for non-card fields. When either the stored or incoming document has a Boss Cards field, the server max-merges those collections into the incoming document. This keeps old clients that omit or resend stale card snapshots from deleting earned account cards, while their other fields retain the existing replacement behavior. PUT does not gain optimistic concurrency; PATCH is the revision-checked card-only route.
|
||||
|
||||
The focused loopback test uses the pinned source server factory, authenticated SQLite accounts, and a temporary database. It covers account isolation, cross-origin PATCH preflight, authorization, normalization, conflict/no-write and retry, concurrent revision checks, stale and omitted-card PUTs, deduplication, and server restart persistence. It does not contact the hosted service or exercise native UI integration.
|
||||
@@ -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.
|
||||
@@ -1,74 +0,0 @@
|
||||
# WoW rank-specific global cooldown categories
|
||||
|
||||
Shared source [issue #3](https://git.whoagland.com/phenom/healer-man/issues/3)
|
||||
owns this combat correction. Native integration belongs to
|
||||
[Codex/healer-man-godot#114](https://git.whoagland.com/Codex/healer-man-godot/issues/114).
|
||||
|
||||
## Imported rank identity
|
||||
|
||||
`scripts/generate-wow335-abilities.mjs` imports Spell.dbc
|
||||
StartRecoveryCategory alongside StartRecoveryTime. The expanded 239-field
|
||||
local enUS layout shifts semantic fields 205/206 to raw columns 211/212.
|
||||
The extracted DBC SHA-256 is
|
||||
`9f0088da35c5ed0b5106cd20e422611489e435b74043e520e02221e0ce1b1af1`.
|
||||
The additive import joins all 2,229 selected learned rank IDs with no missing
|
||||
rows and preserves every original generated value and byte after removing
|
||||
only the inserted `gcdCategoryId` fields. This is an additive metadata import,
|
||||
not a regeneration of spell effects, costs, stats or equipment.
|
||||
|
||||
`wow335AbilityCatalog.ts` maps the category into every rank and initial ability;
|
||||
`abilityCatalog.ts` selects `gcdCategoryId` with the learned rank's spell ID,
|
||||
time and effects. Devotion Aura 48941 is category 38/time 1500 at levels
|
||||
74–78; 48942 is category 133/time 1500 from level 79 through 80. Categories
|
||||
are not derived from name, character level or the first rank of the chain.
|
||||
|
||||
## Combat ownership
|
||||
|
||||
`wowGlobalCooldown.ts` owns matching-category admission and timer creation.
|
||||
Player state, PartyMember and CombatActorState carry optional
|
||||
`globalCooldownEndsByCategory` keyed by decimal category string. For WoW,
|
||||
`globalCooldownEndsAt` is the category-133 projection. Starting category 38
|
||||
or 0 preserves the independent category-133 scalar and timer.
|
||||
|
||||
An exact category is checked even when the attempted spell's own duration is
|
||||
zero. A zero-duration action creates no timer. Explicit category 0 is a real
|
||||
key: a positive-duration fixture starts key `"0"` and other categories remain
|
||||
available. None of the current 2,229 learned rows has positive duration in
|
||||
category 0, so ordinary learned category-0 actions continue working during
|
||||
category 133. Legacy entries lacking a category use 133 when duration is
|
||||
positive; a missing-category zero-duration entry retains its bypass.
|
||||
|
||||
Player `castAbility` and party readiness/commit call the same helper.
|
||||
Cast completion and Sudden Doom's internal free Death Coil keep their existing
|
||||
execution exemptions. Actor authority adapters carry category maps in both
|
||||
directions; initialization, reset and native player switches preserve actor
|
||||
ownership. NPC next-action scheduling remains its existing rotation cadence.
|
||||
|
||||
Raw WoW durations below 1000 ms remain unchanged, including five 500 ms
|
||||
Shadowfury rows and Backdraft/party haste paths. Positive non-133 WoW timers
|
||||
retain their raw duration. Ordinary category-133 timers retain their existing
|
||||
haste policy and one-second floor. Runewaker/RoM timing remains scalar-only;
|
||||
this change does not merge stat formulas, allocation, gear pools or rigs.
|
||||
|
||||
The matching-category behavior follows the pinned primary
|
||||
[AzerothCore category manager](https://github.com/azerothcore/azerothcore-wotlk/blob/35aaa5e722093b4f57b59bc234c0311467081b49/src/server/game/Entities/Unit/CharmInfo.cpp#L367-L390)
|
||||
and [Spell admission/creation](https://github.com/azerothcore/azerothcore-wotlk/blob/35aaa5e722093b4f57b59bc234c0311467081b49/src/server/game/Spells/Spell.cpp#L8471-L8527).
|
||||
This is category policy evidence; complete original-client hit tables,
|
||||
spell-attribute haste parity and companion rotation parity remain separate.
|
||||
|
||||
## Validation and publication
|
||||
|
||||
Focused regressions live in `src/game/wowGlobalCooldown.test.ts`. They include
|
||||
exact rank selection and public source character initialization/casting.
|
||||
Native evidence covers the public Ready Discipline cast path and separately
|
||||
labeled category-38/133/0 UI fixtures. The offline native diagnostic profile
|
||||
catalog currently fixes its characters at level 80, so its GPU fixture is not
|
||||
a live level-74 profile claim or additional Paladin certification. Balance
|
||||
certification remains the existing five Ready WoW specializations.
|
||||
|
||||
The published source checkout and installed mirror's combatStore/partyRuntime
|
||||
are equal after CRLF normalization. The native byte-guard manifest instead
|
||||
patches the exact pre-114 installed mirror and preserves its mixed line endings.
|
||||
Native source pins identify installed bytes, not a claim that source Git HEAD
|
||||
alone recreates every asset/export in the native installation. See the owning
|
||||
issues for final commands, validation counts, evidence and review status.
|
||||
@@ -1,46 +0,0 @@
|
||||
# WoW talent-gate Set typing — 2026-10-09
|
||||
|
||||
This narrow source correction addresses shared-source issue [#4](https://git.whoagland.com/phenom/healer-man/issues/4), coordinated with native issue [#236](https://git.whoagland.com/Codex/healer-man-godot/issues/236). It changes only the inferred TypeScript type of the legal talent-node ID set in `src/game/wow335AbilityCatalog.ts`:
|
||||
|
||||
```ts
|
||||
const SOURCE_WOW335_TALENT_NODE_IDS = new Set<string>(WOW335_TALENT_NODES.map((node) => node.id));
|
||||
```
|
||||
|
||||
The runtime membership check and its inputs are unchanged. The explicit `string` type lets the imported talent-node ID pass strict `Set.has` checking without changing any legal node, exemption, ability rank, rotation, or gameplay value.
|
||||
|
||||
## Before and after
|
||||
|
||||
- Baseline catalog SHA-256: `6a7cf9b934026ec4b5beb0de3fe64eda4872efad561d63d25b2c911676d249a4`
|
||||
- Corrected catalog SHA-256: `3ea007068d8a8b6cd0c49d2d4b780cf176dc567e23fe20537e40f49fded14314`
|
||||
- The focused strict baseline emitted exactly one expected `TS2345` at `wow335AbilityCatalog.ts(2605,75)`, on `SOURCE_WOW335_TALENT_NODE_IDS.has(chain.talentNodeId)`.
|
||||
- The corrected source passed strict TypeScript 5.8.3 checking with zero diagnostics.
|
||||
- Three focused regressions passed: cold native import followed by talent preload, legal talent gates plus source-only-node exemptions, and equality with the full legal talent-node set. The targeted Vitest invocation selected these three tests; 41 other tests in that file were skipped.
|
||||
|
||||
The review run is recorded in `../diagnostics/strict-types/after-regression-tool-final2.json`. The sparse authoritative mirror strict check is recorded in `../diagnostics/strict-types/actual-mirror-types-final.json`; that run used TypeScript 5.8.3 with zero diagnostics and skipped runtime tests because the mirror does not contain the runtime fixture/dependencies. The baseline proof is `../diagnostics/strict-types/baseline-regression-tool.json`.
|
||||
|
||||
## Reproduction checker
|
||||
|
||||
`../scripts/check-wow-talent-gate-types.mjs` checks the catalog and its imported TypeScript dependency graph in strict mode. It can also run the three focused regressions when the project root has `vite.config.ts` and the cold-start fixture, and the toolchain root provides TypeScript, Vite, and Vitest.
|
||||
|
||||
From the source review checkout, run both type and runtime checks with:
|
||||
|
||||
```sh
|
||||
node scripts/check-wow-talent-gate-types.mjs \
|
||||
--project-root /path/to/source-review \
|
||||
--toolchain-root /path/to/source-review
|
||||
```
|
||||
|
||||
For a sparse source mirror with an external dependency tree, point the roots separately and omit only the runtime tests:
|
||||
|
||||
```sh
|
||||
node /path/to/source-mirror/scripts/check-wow-talent-gate-types.mjs \
|
||||
--project-root /path/to/source-mirror \
|
||||
--toolchain-root /path/to/toolchain \
|
||||
--skip-runtime
|
||||
```
|
||||
|
||||
`--expect baseline` is available to verify the recorded pre-fix `TS2345`; the default `--expect fixed` requires a clean strict check. The checker is focused evidence, not a full project build, runtime gameplay validation, balance certification, or GPU validation.
|
||||
|
||||
## Scope record
|
||||
|
||||
Coordinator: `/root`, session `01a11cd3-fa7c-7510-ba8f-bc7a3d0310a6`. The Set typing correction was the source-owned change; independent strict-type and focused-regression validation was performed by the validation worker. All workers used GPT-6 Luna with xhigh reasoning for this scoped work.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
function parseArgs(args) {
|
||||
const result = {
|
||||
projectRoot: process.cwd(),
|
||||
toolchainRoot: null,
|
||||
expectation: "fixed",
|
||||
skipRuntime: false,
|
||||
};
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const argument = args[index];
|
||||
if (argument === "--project-root") result.projectRoot = args[++index];
|
||||
else if (argument.startsWith("--project-root=")) result.projectRoot = argument.slice("--project-root=".length);
|
||||
else if (argument === "--toolchain-root") result.toolchainRoot = args[++index];
|
||||
else if (argument.startsWith("--toolchain-root=")) result.toolchainRoot = argument.slice("--toolchain-root=".length);
|
||||
else if (argument === "--expect") result.expectation = args[++index];
|
||||
else if (argument.startsWith("--expect=")) result.expectation = argument.slice("--expect=".length);
|
||||
else if (argument === "--skip-runtime") result.skipRuntime = true;
|
||||
else if (argument === "--help" || argument === "-h") {
|
||||
console.log("Usage: node scripts/check-wow-talent-gate-types.mjs [--project-root DIR] [--toolchain-root DIR] [--expect baseline|fixed] [--skip-runtime]");
|
||||
process.exit(0);
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${argument}`);
|
||||
}
|
||||
}
|
||||
assert.ok(["baseline", "fixed"].includes(result.expectation), `invalid --expect value: ${result.expectation}`);
|
||||
result.projectRoot = path.resolve(result.projectRoot);
|
||||
result.toolchainRoot = path.resolve(result.toolchainRoot ?? result.projectRoot);
|
||||
return result;
|
||||
}
|
||||
|
||||
function typecheckTalentCatalog(projectRoot, toolchainRoot) {
|
||||
const requireFromToolchain = createRequire(path.join(toolchainRoot, "package.json"));
|
||||
const ts = requireFromToolchain("typescript");
|
||||
const catalogPath = path.join(projectRoot, "src", "game", "wow335AbilityCatalog.ts");
|
||||
const viteClientPathCandidates = [
|
||||
path.join(projectRoot, "node_modules", "vite", "client.d.ts"),
|
||||
path.join(toolchainRoot, "node_modules", "vite", "client.d.ts"),
|
||||
];
|
||||
const viteClientPath = viteClientPathCandidates.find(existsSync);
|
||||
assert.ok(viteClientPath, "strict catalog check requires Vite's client.d.ts in the project or toolchain dependency tree");
|
||||
const configPath = path.join(projectRoot, "tsconfig.app.json");
|
||||
let parsedOptions;
|
||||
if (existsSync(configPath)) {
|
||||
const configText = readFileSync(configPath, "utf8");
|
||||
const parsedConfig = ts.parseConfigFileTextToJson(configPath, configText);
|
||||
assert.equal(parsedConfig.error, undefined, `could not parse ${configPath}`);
|
||||
const parsed = ts.parseJsonConfigFileContent(parsedConfig.config, ts.sys, projectRoot, undefined, configPath);
|
||||
assert.equal(parsed.errors.length, 0, `could not resolve ${configPath}: ${parsed.errors.map((error) => ts.flattenDiagnosticMessageText(error.messageText, "\n")).join("; ")}`);
|
||||
parsedOptions = parsed.options;
|
||||
} else {
|
||||
parsedOptions = {
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
module: ts.ModuleKind.ESNext,
|
||||
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
resolveJsonModule: true,
|
||||
allowImportingTsExtensions: true,
|
||||
paths: {},
|
||||
};
|
||||
}
|
||||
|
||||
const shimDirectory = mkdtempSync(path.join(os.tmpdir(), "wow-talent-gate-types-"));
|
||||
const aliasDeclarationPath = path.join(shimDirectory, "dungeonAssetModules.d.ts");
|
||||
writeFileSync(aliasDeclarationPath, [
|
||||
'declare module "@dungeonAssetModules" {',
|
||||
' export const dungeonModules: Readonly<Record<string, string>>;',
|
||||
'}',
|
||||
'',
|
||||
].join("\n"));
|
||||
|
||||
try {
|
||||
const compilerOptions = {
|
||||
...parsedOptions,
|
||||
strict: true,
|
||||
noEmit: true,
|
||||
skipLibCheck: true,
|
||||
incremental: false,
|
||||
composite: false,
|
||||
types: ["node"],
|
||||
baseUrl: projectRoot,
|
||||
typeRoots: [
|
||||
path.join(projectRoot, "node_modules", "@types"),
|
||||
path.join(toolchainRoot, "node_modules", "@types"),
|
||||
],
|
||||
paths: { ...(parsedOptions.paths ?? {}) },
|
||||
};
|
||||
delete compilerOptions.paths["@dungeonAssetModules"];
|
||||
const rootNames = [catalogPath, viteClientPath, aliasDeclarationPath];
|
||||
const program = ts.createProgram({ rootNames, options: compilerOptions });
|
||||
const diagnostics = ts.getPreEmitDiagnostics(program).filter((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error);
|
||||
const simplified = diagnostics.map((diagnostic) => {
|
||||
const location = diagnostic.file && diagnostic.start !== undefined
|
||||
? diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start)
|
||||
: undefined;
|
||||
const sourceLine = diagnostic.file && location
|
||||
? diagnostic.file.text.split(/\r?\n/u)[location.line]
|
||||
: undefined;
|
||||
return {
|
||||
code: diagnostic.code,
|
||||
file: diagnostic.file ? path.relative(projectRoot, diagnostic.file.fileName).split(path.sep).join("/") : null,
|
||||
line: location ? location.line + 1 : null,
|
||||
message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
|
||||
sourceLine: sourceLine?.trim() ?? null,
|
||||
};
|
||||
});
|
||||
return { typescriptVersion: ts.version, diagnostics: simplified };
|
||||
} finally {
|
||||
rmSync(shimDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function runTalentRuntimeRegressions(projectRoot, toolchainRoot) {
|
||||
assert.ok(existsSync(path.join(projectRoot, "vite.config.ts")), "focused runtime checks require vite.config.ts; use --skip-runtime for a source-only mirror without it");
|
||||
assert.ok(existsSync(path.join(projectRoot, "src", "game", "wow335AbilityCatalog.cold-start.fixture.ts")), "focused runtime checks require the cold-start fixture; use --skip-runtime when it is not present");
|
||||
const requireFromToolchain = createRequire(path.join(toolchainRoot, "package.json"));
|
||||
const vitestPath = requireFromToolchain.resolve("vitest/vitest.mjs");
|
||||
const testPath = path.join("src", "game", "wow335AbilityCatalog.test.ts");
|
||||
const matchingTests = [
|
||||
"preserves talent gates across a cold native import and a later talent preload",
|
||||
"uses legal talent gates for learned builds and leaves source-only nodes unlocked",
|
||||
"uses the same legal talent-node set as the full catalog",
|
||||
];
|
||||
const testPattern = matchingTests.join("|");
|
||||
try {
|
||||
const output = execFileSync(process.execPath, [
|
||||
vitestPath,
|
||||
"run",
|
||||
testPath,
|
||||
"--config",
|
||||
"vite.config.ts",
|
||||
"--maxWorkers=1",
|
||||
"--testTimeout=15000",
|
||||
"--testNamePattern",
|
||||
testPattern,
|
||||
], {
|
||||
cwd: projectRoot,
|
||||
encoding: "utf8",
|
||||
stdio: "pipe",
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
const passedMatch = output.match(/Tests\s+(\d+)\s+passed/u);
|
||||
assert.ok(passedMatch, `Vitest did not report a passing test count:\n${output}`);
|
||||
assert.equal(Number(passedMatch[1]), matchingTests.length, `expected all focused talent gate tests to run:\n${output}`);
|
||||
return { passed: Number(passedMatch[1]), tests: matchingTests, output: output.trim() };
|
||||
} catch (error) {
|
||||
const stdout = error.stdout?.toString() ?? "";
|
||||
const stderr = error.stderr?.toString() ?? "";
|
||||
throw new Error(`focused talent runtime tests failed\n${stdout}\n${stderr}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const projectRoot = args.projectRoot;
|
||||
const toolchainRoot = args.toolchainRoot;
|
||||
const catalogPath = path.join(projectRoot, "src", "game", "wow335AbilityCatalog.ts");
|
||||
const catalogText = readFileSync(catalogPath, "utf8");
|
||||
assert.match(catalogText, /SOURCE_WOW335_TALENT_NODE_IDS\.has\(chain\.talentNodeId\)/u, "the actual source catalog must retain the legal-node gate");
|
||||
|
||||
const typecheck = typecheckTalentCatalog(projectRoot, toolchainRoot);
|
||||
const expectedDiagnostic = typecheck.diagnostics.length === 1
|
||||
&& typecheck.diagnostics[0].code === 2345
|
||||
&& typecheck.diagnostics[0].file === "src/game/wow335AbilityCatalog.ts"
|
||||
&& typecheck.diagnostics[0].sourceLine?.includes("SOURCE_WOW335_TALENT_NODE_IDS.has(chain.talentNodeId)");
|
||||
if (args.expectation === "baseline") {
|
||||
assert.ok(expectedDiagnostic, `expected exactly the pre-fix TS2345 at the actual talent Set gate; got ${JSON.stringify(typecheck.diagnostics, null, 2)}`);
|
||||
} else {
|
||||
assert.deepEqual(typecheck.diagnostics, [], `the corrected catalog and its focused dependency graph must type-check cleanly`);
|
||||
}
|
||||
|
||||
const runtime = args.skipRuntime ? null : runTalentRuntimeRegressions(projectRoot, toolchainRoot);
|
||||
const result = {
|
||||
projectRoot,
|
||||
toolchainRoot,
|
||||
catalogSha256: createHash("sha256").update(catalogText).digest("hex"),
|
||||
expectation: args.expectation,
|
||||
typecheck: {
|
||||
typescriptVersion: typecheck.typescriptVersion,
|
||||
strict: true,
|
||||
diagnostics: typecheck.diagnostics,
|
||||
result: expectedDiagnostic ? "expected-baseline-ts2345" : "clean",
|
||||
},
|
||||
runtime,
|
||||
limits: [
|
||||
"Strict-checks the actual catalog and its imported TypeScript dependency graph, with only the bundler's dungeon asset glob import given its declared Readonly<Record<string, string>> type.",
|
||||
"Runs only the three legal talent gate, source-only exemption, and cold/warm preload regressions; it does not run a full project build or balance sweep.",
|
||||
],
|
||||
};
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
@@ -190,7 +190,6 @@ function parseSpells(buffer, castTimes, durations, radii, ranges, icons) {
|
||||
.replace(/\.(?:blp|tga)$/i, "")
|
||||
.toLowerCase(),
|
||||
powerCostPercentage: unsigned(shifted(204)),
|
||||
startRecoveryCategory: unsigned(shifted(205)),
|
||||
startRecoveryTimeMs: unsigned(shifted(206)),
|
||||
effects: Array.from({ length: 3 }, (_, effectIndex) => ({
|
||||
index: effectIndex,
|
||||
@@ -294,9 +293,8 @@ function slimRank(spell, spells) {
|
||||
description: resolveDescription(spell, spells),
|
||||
castTimeMs: spell.castTimeMs,
|
||||
cooldownMs: Math.max(spell.recoveryTimeMs, spell.categoryRecoveryTimeMs),
|
||||
// Keep the category key independent from its StartRecoveryTime duration.
|
||||
// Zero is the DBC signal for abilities that do not start a global cooldown.
|
||||
gcdMs: spell.startRecoveryTimeMs,
|
||||
gcdCategoryId: spell.startRecoveryCategory,
|
||||
// SpellDuration.dbc uses negative durations for permanent/toggled effects.
|
||||
// Preserve those as zero so the runtime converts them to a non-expiring aura
|
||||
// instead of a one-millisecond aura that the AI immediately recasts.
|
||||
@@ -398,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
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import test from "node:test";
|
||||
import { createGameServer } from "./server.mjs";
|
||||
|
||||
function fixtureUsername() {
|
||||
return `card${process.pid.toString(36)}${randomBytes(4).toString("hex")}`;
|
||||
}
|
||||
|
||||
async function startServer(dataDir) {
|
||||
const root = path.join(dataDir, "http");
|
||||
const staticDir = path.join(root, "static");
|
||||
const contentDir = path.join(root, "content");
|
||||
mkdirSync(staticDir, { recursive: true });
|
||||
mkdirSync(contentDir, { recursive: true });
|
||||
const runtime = createGameServer({
|
||||
staticDir, contentDir, dataDir, enableShelvedModes: true, corsOrigins: "https://native-test.invalid",
|
||||
});
|
||||
await new Promise((resolve) => runtime.server.listen(0, "127.0.0.1", resolve));
|
||||
return { runtime, origin: `http://127.0.0.1:${runtime.server.address().port}` };
|
||||
}
|
||||
|
||||
async function stopServer(runtime) {
|
||||
if (!runtime?.server.listening) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
runtime.server.close((error) => error ? reject(error) : resolve());
|
||||
});
|
||||
}
|
||||
|
||||
async function request(origin, route, { method = "GET", token, body } = {}) {
|
||||
const response = await fetch(`${origin}${route}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(body === undefined ? {} : { "Content-Type": "application/json" }),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
|
||||
async function register(origin, username, password) {
|
||||
const result = await request(origin, "/api/auth/register", {
|
||||
method: "POST", body: { username, password },
|
||||
});
|
||||
assert.equal(result.status, 201);
|
||||
return result.body;
|
||||
}
|
||||
|
||||
test("cloud card PATCH checkpoints atomically per authenticated account and legacy PUT cannot erase cards", async () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "healer-boss-card-checkpoint-"));
|
||||
const firstPassword = `A${randomBytes(18).toString("base64url")}`;
|
||||
const secondPassword = `B${randomBytes(18).toString("base64url")}`;
|
||||
const firstUsername = fixtureUsername();
|
||||
const secondUsername = fixtureUsername();
|
||||
let runtime;
|
||||
|
||||
try {
|
||||
let service = await startServer(tempDir);
|
||||
runtime = service.runtime;
|
||||
const unauthenticated = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", body: { expectedRevision: 0, bossCards: {} },
|
||||
});
|
||||
assert.equal(unauthenticated.status, 401);
|
||||
const preflight = await fetch(`${service.origin}/api/cloud-save`, {
|
||||
method: "OPTIONS",
|
||||
headers: {
|
||||
Origin: "https://native-test.invalid",
|
||||
"Access-Control-Request-Method": "PATCH",
|
||||
"Access-Control-Request-Headers": "authorization,content-type",
|
||||
},
|
||||
});
|
||||
assert.equal(preflight.status, 204);
|
||||
assert.equal(preflight.headers.get("access-control-allow-origin"), "https://native-test.invalid");
|
||||
assert.match(preflight.headers.get("access-control-allow-methods"), /\bPATCH\b/);
|
||||
const unauthorizedMalformed = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", token: "not-a-valid-session-token",
|
||||
body: { expectedRevision: 0 },
|
||||
});
|
||||
assert.equal(unauthorizedMalformed.status, 401);
|
||||
|
||||
const firstAccount = await register(service.origin, firstUsername, firstPassword);
|
||||
const secondAccount = await register(service.origin, secondUsername, secondPassword);
|
||||
assert.notEqual(firstAccount.account.id, secondAccount.account.id);
|
||||
assert.ok(firstAccount.token);
|
||||
assert.ok(secondAccount.token);
|
||||
const missingCards = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", token: firstAccount.token, body: { expectedRevision: 0 },
|
||||
});
|
||||
assert.equal(missingCards.status, 400);
|
||||
assert.equal(missingCards.body.code, "invalid_boss_cards");
|
||||
|
||||
const initialFirst = await request(service.origin, "/api/cloud-save", { token: firstAccount.token });
|
||||
const initialSecond = await request(service.origin, "/api/cloud-save", { token: secondAccount.token });
|
||||
assert.equal(initialFirst.body.revision, 0);
|
||||
assert.deepEqual(initialFirst.body.data, { version: 1, characters: [] });
|
||||
assert.equal(initialSecond.body.revision, 0);
|
||||
|
||||
const rawCards = JSON.parse(
|
||||
'{"counts":{"__proto__":{"normal":9},"wow:55":{"normal":2.9,"rare":0,"epic":1.7,"legendary":9007199254740992,"unknown":50},"":{"normal":7}},"lastClaims":{"__proto__":"200:1","spawn:a":"100:2","ignored":42}}',
|
||||
);
|
||||
const firstCheckpoint = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH",
|
||||
token: firstAccount.token,
|
||||
body: { accountId: secondAccount.account.id, expectedRevision: 0, bossCards: rawCards },
|
||||
});
|
||||
assert.equal(firstCheckpoint.status, 200);
|
||||
assert.equal(firstCheckpoint.body.revision, 1);
|
||||
assert.deepEqual(firstCheckpoint.body.data.version, 1);
|
||||
assert.deepEqual(firstCheckpoint.body.data.characters, []);
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].normal, 2);
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].epic, 1);
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].legendary, Number.MAX_SAFE_INTEGER);
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].rare, undefined);
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.counts["wow:55"].unknown, undefined);
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.counts[""], undefined);
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.counts["__proto__"].normal, 9);
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.lastClaims["__proto__"], "200:1");
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.lastClaims["spawn:a"], "100:2");
|
||||
assert.equal(firstCheckpoint.body.data.bossCards.lastClaims.ignored, undefined);
|
||||
|
||||
const conflict = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", token: firstAccount.token,
|
||||
body: { expectedRevision: 0, bossCards: { counts: { "wow:55": { normal: 50 } } } },
|
||||
});
|
||||
assert.equal(conflict.status, 409);
|
||||
assert.equal(conflict.body.code, "revision_conflict");
|
||||
const unchangedAfterConflict = await request(service.origin, "/api/cloud-save", { token: firstAccount.token });
|
||||
assert.deepEqual(unchangedAfterConflict.body, firstCheckpoint.body);
|
||||
|
||||
const invalidRevision = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", token: firstAccount.token,
|
||||
body: { expectedRevision: 1.5, bossCards: {} },
|
||||
});
|
||||
assert.equal(invalidRevision.status, 400);
|
||||
assert.equal(invalidRevision.body.code, "invalid_revision");
|
||||
|
||||
const retried = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", token: firstAccount.token,
|
||||
body: {
|
||||
expectedRevision: 1,
|
||||
bossCards: {
|
||||
counts: { "wow:55": { normal: 5, rare: 1 }, "wow:new": { epic: 1 } },
|
||||
lastClaims: { "spawn:a": "100:1", "spawn:b": "alpha" },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(retried.status, 200);
|
||||
assert.equal(retried.body.revision, 2);
|
||||
assert.equal(retried.body.data.bossCards.counts["wow:55"].normal, 5);
|
||||
assert.equal(retried.body.data.bossCards.counts["wow:55"].rare, 1);
|
||||
assert.equal(retried.body.data.bossCards.counts["wow:new"].epic, 1);
|
||||
assert.equal(retried.body.data.bossCards.lastClaims["spawn:a"], "100:2");
|
||||
|
||||
const otherCheckpoint = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", token: secondAccount.token,
|
||||
body: { expectedRevision: 0, bossCards: { counts: { "rom:9": { rare: 3 } } } },
|
||||
});
|
||||
assert.equal(otherCheckpoint.status, 200);
|
||||
assert.equal(otherCheckpoint.body.revision, 1);
|
||||
assert.deepEqual(Object.keys(otherCheckpoint.body.data.bossCards.counts), ["rom:9"]);
|
||||
const firstStillIsolated = await request(service.origin, "/api/cloud-save", { token: firstAccount.token });
|
||||
assert.equal(firstStillIsolated.body.revision, 2);
|
||||
assert.equal(firstStillIsolated.body.data.bossCards.counts["rom:9"], undefined);
|
||||
|
||||
const stalePut = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PUT", token: firstAccount.token,
|
||||
body: {
|
||||
data: {
|
||||
version: 2,
|
||||
characters: [{ id: "character-a" }],
|
||||
uiPreferences: { theme: "night" },
|
||||
futureField: { preservedDuringThisWrite: true },
|
||||
bossCards: {
|
||||
counts: { "wow:55": { normal: 1 }, "wow:legacy": { legendary: 1 } },
|
||||
lastClaims: { "spawn:a": "99:9", "spawn:c": "claim-c" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(stalePut.status, 200);
|
||||
assert.equal(stalePut.body.revision, 3);
|
||||
const stalePutExpectedData = JSON.parse(
|
||||
'{"version":2,"characters":[{"id":"character-a"}],"uiPreferences":{"theme":"night"},"futureField":{"preservedDuringThisWrite":true},"bossCards":{"counts":{"__proto__":{"normal":9},"wow:55":{"normal":5,"rare":1,"epic":1,"legendary":9007199254740991},"wow:new":{"epic":1},"wow:legacy":{"legendary":1}},"lastClaims":{"__proto__":"200:1","spawn:a":"100:2","spawn:b":"alpha","spawn:c":"claim-c"}}}',
|
||||
);
|
||||
assert.deepEqual(stalePut.body.data, stalePutExpectedData);
|
||||
assert.equal(stalePut.body.data.bossCards.counts["wow:55"].normal, 5);
|
||||
assert.equal(stalePut.body.data.bossCards.counts["wow:55"].rare, 1);
|
||||
assert.equal(stalePut.body.data.bossCards.counts["wow:legacy"].legendary, 1);
|
||||
assert.equal(stalePut.body.data.bossCards.lastClaims["spawn:a"], "100:2");
|
||||
|
||||
const omittedCardPut = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PUT", token: firstAccount.token,
|
||||
body: { data: { version: 3, characters: [], custom: "legacy replacement" } },
|
||||
});
|
||||
assert.equal(omittedCardPut.status, 200);
|
||||
assert.equal(omittedCardPut.body.revision, 4);
|
||||
assert.deepEqual(omittedCardPut.body.data, {
|
||||
version: 3,
|
||||
characters: [],
|
||||
custom: "legacy replacement",
|
||||
bossCards: stalePut.body.data.bossCards,
|
||||
});
|
||||
|
||||
await stopServer(runtime);
|
||||
runtime = null;
|
||||
service = await startServer(tempDir);
|
||||
runtime = service.runtime;
|
||||
const firstLogin = await request(service.origin, "/api/auth/login", {
|
||||
method: "POST", body: { username: firstUsername, password: firstPassword },
|
||||
});
|
||||
const secondLogin = await request(service.origin, "/api/auth/login", {
|
||||
method: "POST", body: { username: secondUsername, password: secondPassword },
|
||||
});
|
||||
assert.equal(firstLogin.status, 200);
|
||||
assert.equal(secondLogin.status, 200);
|
||||
const reopenedFirst = await request(service.origin, "/api/cloud-save", { token: firstLogin.body.token });
|
||||
const reopenedSecond = await request(service.origin, "/api/cloud-save", { token: secondLogin.body.token });
|
||||
assert.deepEqual(reopenedFirst.body.data, omittedCardPut.body.data);
|
||||
assert.deepEqual(reopenedSecond.body.data.bossCards.counts, { "rom:9": { rare: 3 } });
|
||||
|
||||
const concurrent = await Promise.all([0, 1].map(() => request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", token: firstLogin.body.token,
|
||||
body: { expectedRevision: 4, bossCards: stalePut.body.data.bossCards },
|
||||
})));
|
||||
assert.deepEqual(concurrent.map((result) => result.status).sort(), [200, 409]);
|
||||
assert.equal(concurrent.find((result) => result.status === 409).body.code, "revision_conflict");
|
||||
const afterConcurrentCheckpoint = await request(service.origin, "/api/cloud-save", { token: firstLogin.body.token });
|
||||
assert.equal(afterConcurrentCheckpoint.body.revision, 5);
|
||||
assert.deepEqual(afterConcurrentCheckpoint.body.data, omittedCardPut.body.data);
|
||||
const duplicateCheckpoint = await request(service.origin, "/api/cloud-save", {
|
||||
method: "PATCH", token: firstLogin.body.token,
|
||||
body: { expectedRevision: 5, bossCards: stalePut.body.data.bossCards },
|
||||
});
|
||||
assert.equal(duplicateCheckpoint.status, 200);
|
||||
assert.equal(duplicateCheckpoint.body.revision, 6);
|
||||
assert.deepEqual(duplicateCheckpoint.body.data.bossCards, omittedCardPut.body.data.bossCards);
|
||||
} finally {
|
||||
await stopServer(runtime);
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
const BOSS_CARD_RARITIES = ["normal", "rare", "epic", "legendary"];
|
||||
|
||||
export function emptyBossCardCollection() {
|
||||
return { counts: {}, lastClaims: {} };
|
||||
}
|
||||
|
||||
/** Keep this normalization in step with src/game/bossCards.ts. */
|
||||
export function normalizeBossCardCollection(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return emptyBossCardCollection();
|
||||
const raw = value;
|
||||
const counts = Object.create(null);
|
||||
if (raw.counts && typeof raw.counts === "object" && !Array.isArray(raw.counts)) {
|
||||
for (const [key, candidate] of Object.entries(raw.counts)) {
|
||||
if (!key || !candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
|
||||
const entry = {};
|
||||
for (const rarity of BOSS_CARD_RARITIES) {
|
||||
const count = candidate[rarity];
|
||||
if (typeof count === "number" && Number.isFinite(count) && count >= 1) {
|
||||
entry[rarity] = Math.min(Number.MAX_SAFE_INTEGER, Math.floor(count));
|
||||
}
|
||||
}
|
||||
if (Object.keys(entry).length) counts[key] = entry;
|
||||
}
|
||||
}
|
||||
const lastClaims = raw.lastClaims && typeof raw.lastClaims === "object" && !Array.isArray(raw.lastClaims)
|
||||
? Object.fromEntries(Object.entries(raw.lastClaims).filter(([key, claim]) => key && typeof claim === "string"))
|
||||
: {};
|
||||
return { counts, lastClaims };
|
||||
}
|
||||
|
||||
/** Max-reconcile copies of one account collection, matching source mergeBossCardCollections(..., "max"). */
|
||||
export function mergeBossCardCollections(collections) {
|
||||
const counts = Object.create(null);
|
||||
const lastClaims = Object.create(null);
|
||||
for (const value of collections) {
|
||||
const collection = normalizeBossCardCollection(value);
|
||||
for (const [bossKey, entry] of Object.entries(collection.counts)) {
|
||||
const merged = counts[bossKey] ??= {};
|
||||
for (const rarity of BOSS_CARD_RARITIES) {
|
||||
const count = entry[rarity];
|
||||
if (count === undefined) continue;
|
||||
merged[rarity] = Math.max(merged[rarity] ?? 0, count);
|
||||
}
|
||||
}
|
||||
for (const [spawnId, claim] of Object.entries(collection.lastClaims)) {
|
||||
const previous = lastClaims[spawnId];
|
||||
if (!previous) { lastClaims[spawnId] = claim; continue; }
|
||||
if (/^\d+:/.test(previous) && /^\d+:/.test(claim)) {
|
||||
const [previousTime, previousRevision] = previous.split(":").map(Number);
|
||||
const [time, revision] = claim.split(":").map(Number);
|
||||
if (time > previousTime || (time === previousTime && revision > previousRevision)) lastClaims[spawnId] = claim;
|
||||
} else if (claim > previous) lastClaims[spawnId] = claim;
|
||||
}
|
||||
}
|
||||
return { counts, lastClaims };
|
||||
}
|
||||
+70
-3
@@ -26,6 +26,7 @@ import { createBiteSizedLayoutRepository } from "./bite-sized-layouts.mjs";
|
||||
import { BITE_SIZED_LAYOUT_CATALOG, isLayoutBiteSizedDungeon } from "../shared/bite-sized-layouts.mjs";
|
||||
import { createDungeonRaceRepository } from "./dungeon-races.mjs";
|
||||
import { createDungeonSimulationRepository } from "./dungeon-simulation.mjs";
|
||||
import { mergeBossCardCollections } from "./boss-card-collections.mjs";
|
||||
import dungeonLeaderboardCatalog from "../src/game/generated/dungeonLeaderboardCatalog.generated.json" with { type: "json" };
|
||||
|
||||
const scrypt = promisify(scryptCallback);
|
||||
@@ -402,13 +403,78 @@ export function openGameDatabase(options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function cloudSaveDataForWrite(row) {
|
||||
if (!row) return null;
|
||||
try {
|
||||
const data = JSON.parse(row.data_json);
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error("Cloud save data is not an object.");
|
||||
return data;
|
||||
} catch {
|
||||
throw new GameDatabaseError("The cloud save is unreadable.", 500, "cloud_save_corrupt");
|
||||
}
|
||||
}
|
||||
|
||||
function previousCloudSaveForPut(row) {
|
||||
try {
|
||||
return cloudSaveDataForWrite(row);
|
||||
} catch (error) {
|
||||
// Keep legacy PUT's ability to replace an unreadable save document.
|
||||
if (error instanceof GameDatabaseError && error.code === "cloud_save_corrupt") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function saveCloudData(accountId, data) {
|
||||
const now = Date.now();
|
||||
const row = statements.upsertCloudSave.get(accountId, JSON.stringify(data), now);
|
||||
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data };
|
||||
}
|
||||
|
||||
function transactCloudSave(operation) {
|
||||
database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const result = operation();
|
||||
database.exec("COMMIT");
|
||||
return result;
|
||||
} catch (error) {
|
||||
database.exec("ROLLBACK");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function putCloudSave(accountId, data) {
|
||||
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
||||
throw new GameDatabaseError("Cloud save data must be an object.");
|
||||
}
|
||||
const now = Date.now();
|
||||
const row = statements.upsertCloudSave.get(accountId, JSON.stringify(data), now);
|
||||
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data };
|
||||
return transactCloudSave(() => {
|
||||
const previous = previousCloudSaveForPut(statements.cloudSave.get(accountId));
|
||||
const nextData = { ...data };
|
||||
const hasPreviousCards = previous !== null && Object.hasOwn(previous, "bossCards");
|
||||
const hasIncomingCards = Object.hasOwn(data, "bossCards");
|
||||
if (hasPreviousCards || hasIncomingCards) {
|
||||
nextData.bossCards = mergeBossCardCollections([previous?.bossCards, data.bossCards]);
|
||||
}
|
||||
return saveCloudData(accountId, nextData);
|
||||
});
|
||||
}
|
||||
|
||||
function patchCloudSaveBossCards(accountId, bossCards, expectedRevision) {
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) {
|
||||
throw new GameDatabaseError("Expected cloud save revision must be a nonnegative integer.", 400, "invalid_revision");
|
||||
}
|
||||
return transactCloudSave(() => {
|
||||
const row = statements.cloudSave.get(accountId);
|
||||
const currentRevision = row ? Number(row.revision) : 0;
|
||||
if (currentRevision !== expectedRevision) {
|
||||
throw new GameDatabaseError("Cloud save revision changed. Reload before retrying.", 409, "revision_conflict");
|
||||
}
|
||||
const previous = cloudSaveDataForWrite(row) ?? { version: 1, characters: [] };
|
||||
const nextData = {
|
||||
...previous,
|
||||
bossCards: mergeBossCardCollections([previous.bossCards, bossCards]),
|
||||
};
|
||||
return saveCloudData(accountId, nextData);
|
||||
});
|
||||
}
|
||||
|
||||
function isGameMaster(account) {
|
||||
@@ -1213,6 +1279,7 @@ export function openGameDatabase(options = {}) {
|
||||
logout: (token) => { if (typeof token === "string" && token) statements.deleteSession.run(tokenHash(token)); },
|
||||
getCloudSave,
|
||||
putCloudSave,
|
||||
patchCloudSaveBossCards,
|
||||
isGameMaster,
|
||||
getManastormConfig,
|
||||
putManastormConfig,
|
||||
|
||||
+10
-1
@@ -209,7 +209,7 @@ export function createGameServer(options = {}) {
|
||||
response.writeHead(204, {
|
||||
...cors,
|
||||
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
||||
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
});
|
||||
response.end();
|
||||
@@ -445,6 +445,15 @@ export function createGameServer(options = {}) {
|
||||
json(response, 200, gameDatabase.putCloudSave(account.id, body.data));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/cloud-save" && request.method === "PATCH") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
const body = await readJson(request, bodyLimit);
|
||||
if (!body || typeof body !== "object" || Array.isArray(body) || !Object.hasOwn(body, "bossCards")) {
|
||||
throw new GameDatabaseError("Boss Card collection is required.", 400, "invalid_boss_cards");
|
||||
}
|
||||
json(response, 200, gameDatabase.patchCloudSaveBossCards(account.id, body.bossCards, body.expectedRevision));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/online-group" && request.method === "GET") {
|
||||
const account = gameDatabase.authenticate(bearerToken(request));
|
||||
json(response, 200, gameDatabase.onlineGroupState(account.id));
|
||||
|
||||
@@ -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;
|
||||
@@ -336,8 +337,6 @@ export interface AbilityRankDefinition {
|
||||
readonly channelPulses?: number;
|
||||
readonly channelStartsWithPulse?: boolean;
|
||||
readonly cooldownMs: number;
|
||||
/** Raw Spell.dbc StartRecoveryCategory; category 0 is a real key. */
|
||||
readonly gcdCategoryId?: number;
|
||||
readonly gcdMs: number;
|
||||
readonly cost: AbilityCost;
|
||||
readonly costs?: readonly AbilityCost[];
|
||||
@@ -371,8 +370,6 @@ export interface AbilityDefinition {
|
||||
readonly channelStartsWithPulse?: boolean;
|
||||
readonly cooldownMs: number;
|
||||
readonly cooldownGroup?: string;
|
||||
/** Raw selected-rank Spell.dbc StartRecoveryCategory; category 0 is a real key. */
|
||||
readonly gcdCategoryId?: number;
|
||||
readonly gcdMs: number;
|
||||
readonly cost: AbilityCost;
|
||||
readonly costs?: readonly AbilityCost[];
|
||||
@@ -1275,7 +1272,6 @@ export function abilityAtLevel(
|
||||
channelPulses: rank.channelPulses,
|
||||
channelStartsWithPulse: rank.channelStartsWithPulse,
|
||||
cooldownMs: rank.cooldownMs,
|
||||
gcdCategoryId: rank.gcdCategoryId,
|
||||
gcdMs: rank.gcdMs,
|
||||
cost: rank.cost,
|
||||
costs: rank.costs ?? [rank.cost],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,6 @@ export function companionActorFromMember(
|
||||
resources,
|
||||
cooldowns: actor?.cooldowns ?? member.cooldowns,
|
||||
globalCooldownEndsAt: actor?.globalCooldownEndsAt ?? member.globalCooldownEndsAt,
|
||||
globalCooldownEndsByCategory: actor?.globalCooldownEndsByCategory ?? member.globalCooldownEndsByCategory,
|
||||
cast: actor?.cast ?? member.activeCast,
|
||||
controlledUntil: actor?.controlledUntil ?? member.controlledUntil,
|
||||
schoolLockedUntil: actor?.schoolLockedUntil ?? member.schoolLockedUntil,
|
||||
@@ -129,7 +128,6 @@ export function memberFromCompanionActor(member: PartyMember, actor?: CombatActo
|
||||
comboTargetId: actor.comboTargetId,
|
||||
cooldowns: actor.cooldowns,
|
||||
globalCooldownEndsAt: actor.globalCooldownEndsAt,
|
||||
globalCooldownEndsByCategory: actor.globalCooldownEndsByCategory,
|
||||
activeCast: actor.cast,
|
||||
activeCastTargetId: actor.cast?.targetId ?? member.activeCastTargetId,
|
||||
schoolLockedUntil: actor.schoolLockedUntil,
|
||||
@@ -160,7 +158,6 @@ export function companionActorWithMemberRuntime(
|
||||
resources,
|
||||
cooldowns: member.cooldowns,
|
||||
globalCooldownEndsAt: member.globalCooldownEndsAt,
|
||||
globalCooldownEndsByCategory: member.globalCooldownEndsByCategory,
|
||||
cast: member.activeCast ? { ...member.activeCast, targetId: member.activeCastTargetId } : null,
|
||||
controlledUntil: member.controlledUntil,
|
||||
schoolLockedUntil: member.schoolLockedUntil,
|
||||
|
||||
@@ -69,7 +69,6 @@ export interface CombatActorState {
|
||||
readonly resources: Readonly<Record<CombatResourceId, CombatResourcePoolState>>;
|
||||
readonly cooldowns: Readonly<Record<string, number>>;
|
||||
readonly globalCooldownEndsAt: number;
|
||||
readonly globalCooldownEndsByCategory?: Readonly<Record<string, number>>;
|
||||
readonly cast: CombatActorCastState | null;
|
||||
readonly controlledUntil: number;
|
||||
readonly schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
+207
-43
@@ -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,
|
||||
@@ -11,7 +12,6 @@ import { DEFAULT_GRAPHICS_SETTINGS, normalizeGraphicsSettings, type GraphicsSett
|
||||
import { permanentBuffEffects } from "./partyBuffPolicy";
|
||||
import { usePartyBuffPolicyStore } from "./partyBuffPolicyStore";
|
||||
import { combatNow } from "./combatClock";
|
||||
import { abilityGcdCategory, abilityGlobalCooldownActive, startAbilityGlobalCooldown } from "./wowGlobalCooldown";
|
||||
import { selectEnemyChainTargets } from "./enemyChainTargeting";
|
||||
import { mergePassiveAuraResourceModifiers } from "./classPassiveResourceModifiers";
|
||||
import { enemyPeriodicDamageSource, enemyPeriodicDamageTickAmount } from "./enemyPeriodicDamage";
|
||||
@@ -65,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";
|
||||
@@ -456,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,
|
||||
@@ -1406,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;
|
||||
@@ -1571,6 +1575,7 @@ export interface EnemyPeriodicCombatEffect {
|
||||
export interface CombatState {
|
||||
classId: ClassId;
|
||||
statRuleset: CharacterStatRuleset;
|
||||
contentCategoryId: ContentCategoryId;
|
||||
readonly romNativeRecoveryClock?: RomNativeRecoveryClock;
|
||||
hunterPetFamily: Wow335HunterPetFamily;
|
||||
specializationId: string | null;
|
||||
@@ -1600,7 +1605,6 @@ export interface CombatState {
|
||||
abilities: readonly AbilityDefinition[];
|
||||
cooldowns: Readonly<Record<string, number>>;
|
||||
globalCooldownEndsAt: number;
|
||||
globalCooldownEndsByCategory?: Readonly<Record<string, number>>;
|
||||
activeCast: ActiveCast | null;
|
||||
schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
|
||||
playerAnimationEvent: CharacterCombatAnimationEvent | null;
|
||||
@@ -1710,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;
|
||||
@@ -1820,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"
|
||||
> {
|
||||
@@ -1906,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)];
|
||||
}
|
||||
|
||||
@@ -1971,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(
|
||||
@@ -2382,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"
|
||||
@@ -4287,13 +4349,10 @@ function talentAdjustedGcd(
|
||||
statRuleset: CharacterStatRuleset = "wow",
|
||||
): number {
|
||||
if (ability.gcdMs <= 0) return 0;
|
||||
const normalizedStatRuleset = normalizeCharacterStatRuleset(statRuleset, classId);
|
||||
if (normalizedStatRuleset === "wow" && (ability.gcdMs < 1_000
|
||||
|| ability.source !== "runewaker" && abilityGcdCategory({ statRuleset }, ability) !== 133)) return ability.gcdMs;
|
||||
const hasteMultiplier = applyTalentModifierValue(1, modifiers, "haste-percent", abilityTalentContext(ability));
|
||||
const duration = hastedDuration(
|
||||
ability.gcdMs,
|
||||
normalizedStatRuleset === "rom"
|
||||
normalizeCharacterStatRuleset(statRuleset, classId) === "rom"
|
||||
? 0
|
||||
: ratingPercent(gearStats.hasteRating, "spellHaste", level, classId),
|
||||
1_000,
|
||||
@@ -7657,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) {
|
||||
@@ -7746,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
|
||||
@@ -7804,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)
|
||||
@@ -7812,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;
|
||||
@@ -9438,6 +9502,12 @@ function maybeMirrorDeathKnightDancingRuneWeaponMeleeInWork(
|
||||
source,
|
||||
school,
|
||||
work.gearStats.spellPenetration,
|
||||
undefined,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9551,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) {
|
||||
@@ -9918,6 +9989,7 @@ function resolveAndDamageMobInWork(
|
||||
: sourceWeaponChannel === "off-hand" ? "offhand"
|
||||
: sourceWeaponChannel === "ranged" ? "ranged" : undefined
|
||||
: undefined,
|
||||
reportedAbilityId,
|
||||
);
|
||||
maybeProcShamanFlurryInWork(
|
||||
work,
|
||||
@@ -11384,7 +11456,6 @@ function mirrorCompanionActor(actor: CombatActorState): void {
|
||||
lastResourceSpentAt: mirrored.lastResourceSpentAt,
|
||||
cooldowns: mirrored.cooldowns,
|
||||
globalCooldownEndsAt: mirrored.globalCooldownEndsAt,
|
||||
globalCooldownEndsByCategory: mirrored.globalCooldownEndsByCategory,
|
||||
activeCast: mirrored.activeCast,
|
||||
activeCastTargetId: mirrored.activeCastTargetId,
|
||||
schoolLockedUntil: mirrored.schoolLockedUntil,
|
||||
@@ -11722,7 +11793,6 @@ function combatActorSnapshots(state: CombatState): Readonly<Record<string, Comba
|
||||
resources: playerResources,
|
||||
cooldowns: state.cooldowns,
|
||||
globalCooldownEndsAt: state.globalCooldownEndsAt,
|
||||
globalCooldownEndsByCategory: state.globalCooldownEndsByCategory,
|
||||
cast: state.activeCast ? {
|
||||
abilityId: state.activeCast.abilityId,
|
||||
school: state.activeCast.school,
|
||||
@@ -11761,7 +11831,6 @@ function combatActorSnapshots(state: CombatState): Readonly<Record<string, Comba
|
||||
: {},
|
||||
cooldowns: mob.abilityReadyAt,
|
||||
globalCooldownEndsAt: 0,
|
||||
globalCooldownEndsByCategory: {},
|
||||
cast: mob.activeCast ? {
|
||||
abilityId: mob.activeCast.attackId,
|
||||
school: mob.activeCast.school,
|
||||
@@ -11794,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;
|
||||
@@ -11813,6 +11883,7 @@ function initialState() {
|
||||
return {
|
||||
classId,
|
||||
statRuleset,
|
||||
contentCategoryId,
|
||||
hunterPetFamily: "wolf" as const,
|
||||
specializationId: null,
|
||||
gearRole: "healer" as PartyRole,
|
||||
@@ -11842,7 +11913,6 @@ function initialState() {
|
||||
abilities,
|
||||
cooldowns: {},
|
||||
globalCooldownEndsAt: 0,
|
||||
globalCooldownEndsByCategory: {},
|
||||
activeCast: null,
|
||||
schoolLockedUntil: {},
|
||||
playerAnimationEvent: null,
|
||||
@@ -11902,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,
|
||||
@@ -11912,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,
|
||||
@@ -11961,6 +12035,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
raceId,
|
||||
gender,
|
||||
statRuleset,
|
||||
contentCategoryId,
|
||||
romNativeRecoveryClock: undefined,
|
||||
level: progression.level,
|
||||
xp: progression.xp,
|
||||
@@ -11987,7 +12062,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
abilities,
|
||||
talentRanks,
|
||||
),
|
||||
inventory,
|
||||
inventory: categoryInventory,
|
||||
bossCards: normalizeBossCardCollection(profile.bossCards),
|
||||
equipment,
|
||||
gearStats,
|
||||
@@ -11997,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",
|
||||
@@ -12007,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,
|
||||
@@ -12268,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
|
||||
@@ -12278,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,
|
||||
@@ -12341,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) {
|
||||
@@ -12351,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,
|
||||
@@ -12359,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,
|
||||
@@ -12412,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;
|
||||
},
|
||||
|
||||
@@ -12420,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,
|
||||
@@ -12428,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,
|
||||
@@ -13623,7 +13716,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
) failure = "player-controlled";
|
||||
else if (state.activeCast && !completingCast) failure = "already-casting";
|
||||
else if (!completingCast && !suddenDoomFreeDeathCoil
|
||||
&& abilityGlobalCooldownActive(state, ability, now)) failure = "global-cooldown";
|
||||
&& ability.gcdMs > 0 && state.globalCooldownEndsAt > now) failure = "global-cooldown";
|
||||
else if (!completingCast && !consumesHunterLockAndLoad && (state.cooldowns[abilityId] ?? 0) > now) failure = "cooldown";
|
||||
else if ((state.schoolLockedUntil[spellSchool] ?? 0) > now) failure = "school-locked";
|
||||
else if (ability.id === "rom-mage-primary-55" && !hasRuneWakerState("rom-charged")) failure = "condition-not-met";
|
||||
@@ -13643,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-")
|
||||
@@ -14194,7 +14289,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
state.classId,
|
||||
state.statRuleset,
|
||||
);
|
||||
const gcdMs = warlockBackdraftEffects && gcdBeforeBackdraftMs >= 1_000
|
||||
const gcdMs = warlockBackdraftEffects && gcdBeforeBackdraftMs > 0
|
||||
? Math.max(1_000, Math.round(gcdBeforeBackdraftMs * (1 - warlockBackdraftEffects.reductionPercent / 100)))
|
||||
: gcdBeforeBackdraftMs;
|
||||
const improvedRuneTapRank = state.classId === "death-knight" && ability.name === "Rune Tap"
|
||||
@@ -14410,7 +14505,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
critical: false,
|
||||
outcome: "started",
|
||||
}),
|
||||
...startAbilityGlobalCooldown(state, ability, now, gcdMs),
|
||||
globalCooldownEndsAt: gcdMs > 0 ? now + gcdMs : state.globalCooldownEndsAt,
|
||||
selectedTargetId: effectiveTarget === "hostile" ? targetId : state.selectedTargetId,
|
||||
feedback: nextFeedback(
|
||||
"success",
|
||||
@@ -16857,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];
|
||||
@@ -17874,8 +17985,8 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
areaEffects: work.areaEffects,
|
||||
lastCombatResult: work.combatResults.at(-1) ?? state.lastCombatResult,
|
||||
cooldowns,
|
||||
...startAbilityGlobalCooldown(state, ability, now,
|
||||
completingCast || suddenDoomFreeDeathCoil ? 0 : gcdMs),
|
||||
globalCooldownEndsAt: completingCast || suddenDoomFreeDeathCoil || gcdMs <= 0
|
||||
? state.globalCooldownEndsAt : now + gcdMs,
|
||||
activeCast: null,
|
||||
...autoAttackUpdate,
|
||||
...autoShotUpdate,
|
||||
@@ -18654,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();
|
||||
@@ -20546,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;
|
||||
@@ -21430,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") {
|
||||
@@ -21491,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, {
|
||||
@@ -21702,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
|
||||
@@ -21726,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
|
||||
@@ -24315,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;
|
||||
@@ -24385,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
|
||||
@@ -26559,7 +26724,6 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
deathKnightRunes: state.classId === "death-knight" ? createDeathKnightRunes() : [],
|
||||
cooldowns: {},
|
||||
globalCooldownEndsAt: 0,
|
||||
globalCooldownEndsByCategory: {},
|
||||
activeCast: null,
|
||||
schoolLockedUntil: {},
|
||||
playerAnimationEvent: nextAnimationEvent("ability-cancel"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,10 +16,6 @@ import {
|
||||
type RotationFriendlyTarget,
|
||||
} from "./combatRotation";
|
||||
import { spendDeathKnightRunes } from "./combatResources";
|
||||
import {
|
||||
abilityGlobalCooldownActive,
|
||||
startAbilityGlobalCooldown,
|
||||
} from "./wowGlobalCooldown";
|
||||
import { resolveResourceValue } from "./combatAuras";
|
||||
import { combatHasLineOfSight } from "./combatSpatial";
|
||||
import { equipmentAdjustedCombatAmount, useCombatStore, type MobCombatState } from "./combatStore";
|
||||
@@ -349,7 +345,7 @@ export function partyAbilityIsReady(
|
||||
if (!romPriestWarriorStanceUseAllowed(ability, activeAuraTags)) return false;
|
||||
}
|
||||
const hasRomPriestChampionCooldownOverride = partyRomPriestChampionCooldownOverride(member, ability) !== null;
|
||||
if (abilityGlobalCooldownActive(member, ability, now)
|
||||
if ((ability.gcdMs > 0 && member.globalCooldownEndsAt > now)
|
||||
|| (!partyHunterLockAndLoadReady(member, ability, now)
|
||||
&& !hasRomPriestChampionCooldownOverride
|
||||
&& (member.cooldowns[ability.id] ?? 0) > now)) return false;
|
||||
@@ -590,7 +586,6 @@ export function commitPartyAbilityUse(
|
||||
const adjustedCooldownMs = Math.max(0,
|
||||
cooldownMs - knightWarriorCooldownReductionMs - warriorDruidCooldownReductionMs - knightCompassionCooldownReductionMs);
|
||||
const cooldowns = { ...member.cooldowns };
|
||||
const globalCooldown = startAbilityGlobalCooldown(member, ability, now, ability.gcdMs);
|
||||
if (romCooldownOverride !== null) delete cooldowns[ability.id];
|
||||
if (adjustedCooldownMs > 0) cooldowns[ability.id] = now + adjustedCooldownMs;
|
||||
return {
|
||||
@@ -601,7 +596,7 @@ export function commitPartyAbilityUse(
|
||||
lastResourceSpentAt,
|
||||
runes,
|
||||
cooldowns,
|
||||
...globalCooldown,
|
||||
globalCooldownEndsAt: ability.gcdMs > 0 ? now + ability.gcdMs : member.globalCooldownEndsAt,
|
||||
tauntReadyAt: aiHintsForAbility(ability).categories.includes("taunt")
|
||||
? now + Math.max(8_000, ability.cooldownMs)
|
||||
: member.tauntReadyAt,
|
||||
|
||||
+24
-15
@@ -1,3 +1,4 @@
|
||||
import { eligibleSacrificeMinion } from "./sacrificeMinion";
|
||||
import { healerManaRecoveryAmount } from "./healerManaRecovery";
|
||||
import { nativeDisarmedMainHandAutoAttackProfile, nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount";
|
||||
import { romNativePlayerHitProfile } from "./romNativeCombatResolution";
|
||||
@@ -47,7 +48,6 @@ import {
|
||||
useCombatStore,
|
||||
} from "./combatStore";
|
||||
import { raceAffixHasteMultiplier } from "./dungeonRaceAffixBuffs";
|
||||
import { abilityGcdCategory, startAbilityGlobalCooldown } from "./wowGlobalCooldown";
|
||||
import { activeRaceOrbId } from "./dungeonRaceAffixRuntime";
|
||||
import {
|
||||
WOW335_PALADIN_ILLUMINATION_TALENT_ID,
|
||||
@@ -970,9 +970,9 @@ function partyWarlockBackdraftCast(
|
||||
const haste = raceAffixHasteMultiplier(now);
|
||||
const reduction = effects.reductionPercent / 100;
|
||||
const castTimeMs = Math.max(0, Math.round(Math.round(ability.castTimeMs / haste) * (1 - reduction)));
|
||||
const gcdMs = ability.gcdMs >= 1_000 && abilityGcdCategory(member, ability) === 133
|
||||
const gcdMs = ability.gcdMs > 0
|
||||
? Math.max(1_000, Math.round(Math.round(ability.gcdMs / haste) * (1 - reduction)))
|
||||
: ability.gcdMs;
|
||||
: 0;
|
||||
return {
|
||||
ability: { ...ability, castTimeMs, gcdMs },
|
||||
auraInstanceId: aura.instanceId,
|
||||
@@ -2667,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,
|
||||
@@ -3191,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)
|
||||
@@ -4588,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];
|
||||
@@ -5148,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) {
|
||||
@@ -5647,14 +5660,12 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
continue;
|
||||
}
|
||||
applyPartyRomChampionRunePulseOnUse(member, castAbility, now);
|
||||
if (castingHastePercent > 0 && castAbility.gcdMs > 0
|
||||
&& (abilityGcdCategory(committed, castAbility) === null
|
||||
|| abilityGcdCategory(committed, castAbility) === 133 && castAbility.gcdMs >= 1_000)) {
|
||||
if (castAbility.gcdMs > 0 && castingHastePercent > 0) {
|
||||
committed = {
|
||||
...committed,
|
||||
...startAbilityGlobalCooldown(committed, castAbility, now, Math.max(1_000, Math.round(
|
||||
globalCooldownEndsAt: now + Math.max(1_000, Math.round(
|
||||
castAbility.gcdMs / (1 + castingHastePercent / 100),
|
||||
))),
|
||||
)),
|
||||
};
|
||||
}
|
||||
applyPartyDeathKnightBladeBarrier(member, committed, castAbility, now);
|
||||
@@ -5953,14 +5964,12 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
continue;
|
||||
}
|
||||
applyPartyRomChampionRunePulseOnUse(runtimeMember, castAbility, now);
|
||||
if (castingHastePercent > 0 && castAbility.gcdMs > 0
|
||||
&& (abilityGcdCategory(committed, castAbility) === null
|
||||
|| abilityGcdCategory(committed, castAbility) === 133 && castAbility.gcdMs >= 1_000)) {
|
||||
if (castAbility.gcdMs > 0 && castingHastePercent > 0) {
|
||||
committed = {
|
||||
...committed,
|
||||
...startAbilityGlobalCooldown(committed, castAbility, now, Math.max(1_000, Math.round(
|
||||
globalCooldownEndsAt: now + Math.max(1_000, Math.round(
|
||||
castAbility.gcdMs / (1 + castingHastePercent / 100),
|
||||
))),
|
||||
)),
|
||||
};
|
||||
}
|
||||
applyPartyDeathKnightBladeBarrier(runtimeMember, committed, castAbility, now);
|
||||
|
||||
@@ -118,7 +118,6 @@ export interface PartyMember {
|
||||
readonly comboTargetId: string | null;
|
||||
readonly cooldowns: Readonly<Record<string, number>>;
|
||||
readonly globalCooldownEndsAt: number;
|
||||
readonly globalCooldownEndsByCategory?: Readonly<Record<string, number>>;
|
||||
readonly activeCast: PartyActiveCast | null;
|
||||
readonly activeCastTargetId: string | null;
|
||||
readonly schoolLockedUntil: Readonly<Partial<Record<DamageSchool, number>>>;
|
||||
@@ -503,7 +502,6 @@ function createMember(
|
||||
comboTargetId: null,
|
||||
cooldowns: Object.freeze({}),
|
||||
globalCooldownEndsAt: 0,
|
||||
globalCooldownEndsByCategory: {},
|
||||
activeCast: null,
|
||||
activeCastTargetId: null,
|
||||
schoolLockedUntil: Object.freeze({}),
|
||||
@@ -696,7 +694,6 @@ export interface PartyState {
|
||||
| "lastResourceSpentAt"
|
||||
| "cooldowns"
|
||||
| "globalCooldownEndsAt"
|
||||
| "globalCooldownEndsByCategory"
|
||||
| "activeCast"
|
||||
| "activeCastTargetId"
|
||||
| "schoolLockedUntil"
|
||||
|
||||
+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);
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { preloadRuntimeTalentCatalog, runtimeTalentNodeById } from "./talentRuntimeCatalog";
|
||||
import generatedData from "./wow335AbilityData.generated.json";
|
||||
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
||||
import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
|
||||
|
||||
const legalNodeIds = new Set(WOW335_TALENT_NODES.map((node) => node.id));
|
||||
const sourceTalentChains = Object.values(generatedData.classes)
|
||||
.flat()
|
||||
.filter((chain) => chain.talentNodeId !== undefined);
|
||||
const absentSourceNodeIds = sourceTalentChains
|
||||
.map((chain) => chain.talentNodeId!)
|
||||
.filter((nodeId) => !legalNodeIds.has(nodeId))
|
||||
.sort();
|
||||
|
||||
assert.equal(runtimeTalentNodeById("talent-484"), null, "native bridge starts without the asynchronous talent snapshot");
|
||||
assert.equal(sourceTalentChains.length, 145);
|
||||
assert.equal(sourceTalentChains.filter((chain) => legalNodeIds.has(chain.talentNodeId!)).length, 142);
|
||||
assert.deepEqual(absentSourceNodeIds, ["talent-1666", "talent-1714", "talent-2249"]);
|
||||
|
||||
function assertCatalogTalentGates(): void {
|
||||
const abilitiesById = new Map(Object.values(WOW335_ABILITIES_BY_CLASS)
|
||||
.flat()
|
||||
.map((ability) => [ability.id, ability]));
|
||||
for (const chain of sourceTalentChains) {
|
||||
const ability = abilitiesById.get(chain.id);
|
||||
assert.ok(ability, `source talent chain ${chain.id} remains exported`);
|
||||
assert.equal(
|
||||
ability.talentNodeId,
|
||||
legalNodeIds.has(chain.talentNodeId!) ? chain.talentNodeId : undefined,
|
||||
`source talent gate for ${chain.id}`,
|
||||
);
|
||||
}
|
||||
|
||||
const priestVe = abilitiesById.get("wow335-priest-vampiric-embrace");
|
||||
const warriorDevastate = abilitiesById.get("wow335-warrior-devastate");
|
||||
const rogueShadowstep = abilitiesById.get("wow335-rogue-shadowstep");
|
||||
const shamanLavaLash = abilitiesById.get("wow335-shaman-lava-lash");
|
||||
assert.ok(priestVe);
|
||||
assert.ok(warriorDevastate);
|
||||
assert.ok(rogueShadowstep);
|
||||
assert.ok(shamanLavaLash);
|
||||
assert.equal(priestVe.talentNodeId, "talent-484");
|
||||
assert.equal(warriorDevastate.talentNodeId, undefined);
|
||||
assert.equal(rogueShadowstep.talentNodeId, undefined);
|
||||
assert.equal(shamanLavaLash.talentNodeId, undefined);
|
||||
}
|
||||
|
||||
assertCatalogTalentGates();
|
||||
await preloadRuntimeTalentCatalog();
|
||||
assert.ok(runtimeTalentNodeById("talent-484"));
|
||||
assert.equal(runtimeTalentNodeById("talent-1666"), null);
|
||||
assertCatalogTalentGates();
|
||||
@@ -1,12 +1,8 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
abilityAtLevel,
|
||||
abilityById,
|
||||
isAbilityUnlocked,
|
||||
learnedAbilityForBuild,
|
||||
preloadAbilityCatalogsForClasses,
|
||||
type AbilityDefinition,
|
||||
type AbilityEffect,
|
||||
@@ -187,85 +183,6 @@ describe("WoW 3.3.5 executable effect mapping", () => {
|
||||
expect(livingBomb?.ranks.find((rank) => rank.spellId === 55_359)?.description).toContain("512 Fire damage");
|
||||
});
|
||||
|
||||
it("keeps DBC aura and trigger healing tooltips out of direct-heal semantics", () => {
|
||||
const vampiricEmbrace = byId("wow335-priest-vampiric-embrace");
|
||||
const sourceVampiricEmbrace = generatedData.classes.priest.find((chain) => chain.name === "Vampiric Embrace");
|
||||
const sourceRank = sourceVampiricEmbrace?.ranks.find((rank) => rank.spellId === 15_286);
|
||||
expect(sourceRank?.effects).toContainEqual(expect.objectContaining({
|
||||
effectType: 6,
|
||||
auraType: 4,
|
||||
implicitTargetA: 6,
|
||||
triggerSpellId: 0,
|
||||
}));
|
||||
expect(vampiricEmbrace).toMatchObject({ target: "hostile", talentNodeId: "talent-484" });
|
||||
expect(effectsOfKind(vampiricEmbrace, "heal")).toEqual([]);
|
||||
expect(effectsOfKind(vampiricEmbrace, "damage")).toEqual([]);
|
||||
expect(effectsOfKind(vampiricEmbrace, "apply-aura")).toContainEqual(expect.objectContaining({
|
||||
sourceEffectType: 6,
|
||||
sourceAuraType: 4,
|
||||
aura: expect.objectContaining({ disposition: "debuff" }),
|
||||
}));
|
||||
|
||||
const judgementOfLight = byId("wow335-paladin-judgement-of-light");
|
||||
const sourceJudgementOfLight = generatedData.classes.paladin.find((chain) => chain.name === "Judgement of Light");
|
||||
expect(sourceJudgementOfLight?.ranks[0]?.effects).toContainEqual(expect.objectContaining({
|
||||
effectType: 6,
|
||||
auraType: 4,
|
||||
triggerSpellId: 0,
|
||||
}));
|
||||
expect(effectsOfKind(judgementOfLight, "heal")).toEqual([]);
|
||||
expect(effectsOfKind(judgementOfLight, "damage")).toEqual([]);
|
||||
expect(effectsOfKind(judgementOfLight, "apply-aura")).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
aura: expect.objectContaining({
|
||||
procs: expect.arrayContaining([expect.objectContaining({
|
||||
action: expect.objectContaining({ kind: "heal" }),
|
||||
})]),
|
||||
}),
|
||||
}),
|
||||
]));
|
||||
});
|
||||
|
||||
it("preserves source-backed direct and party healing alongside aura tooltip filtering", () => {
|
||||
const flashHeal = byId("wow335-priest-flash-heal");
|
||||
const rawFlashHeal = generatedData.classes.priest.find((chain) => chain.name === "Flash Heal");
|
||||
expect(rawFlashHeal?.ranks.every((rank) => rank.effects.some((effect) => effect.effectType === 10))).toBe(true);
|
||||
expect(flashHeal.ranks?.every((rank) => effectsOfKind(rank, "heal").length === 1)).toBe(true);
|
||||
|
||||
const divineHymn = byId("wow335-priest-divine-hymn");
|
||||
expect(effectsOfKind(divineHymn, "heal")).toEqual([
|
||||
expect.objectContaining({ scope: "party", maximumTargets: 3, radius: 40 }),
|
||||
]);
|
||||
const divineStorm = byId("wow335-paladin-divine-storm");
|
||||
expect(effectsOfKind(divineStorm, "heal")).toContainEqual(expect.objectContaining({ scope: "party" }));
|
||||
});
|
||||
|
||||
it("preserves talent gates across a cold native import and a later talent preload", () => {
|
||||
const projectRoot = process.cwd();
|
||||
const viteNode = path.join(projectRoot, "node_modules", "vite-node", "vite-node.mjs");
|
||||
const fixture = fileURLToPath(new URL("./wow335AbilityCatalog.cold-start.fixture.ts", import.meta.url));
|
||||
expect(() => execFileSync(process.execPath, [viteNode, "--mode", "godot-bridge", fixture], {
|
||||
cwd: projectRoot,
|
||||
stdio: "pipe",
|
||||
})).not.toThrow();
|
||||
});
|
||||
|
||||
it("uses legal talent gates for learned builds and leaves source-only nodes unlocked", () => {
|
||||
const vampiricEmbrace = byId("wow335-priest-vampiric-embrace");
|
||||
expect(learnedAbilityForBuild(vampiricEmbrace, 80, {})).toBeNull();
|
||||
expect(learnedAbilityForBuild(vampiricEmbrace, 80, { "talent-484": 1 })).not.toBeNull();
|
||||
|
||||
for (const id of [
|
||||
"wow335-warrior-devastate",
|
||||
"wow335-rogue-shadowstep",
|
||||
"wow335-shaman-lava-lash",
|
||||
]) {
|
||||
const ability = byId(id);
|
||||
expect(ability.talentNodeId).toBeUndefined();
|
||||
expect(learnedAbilityForBuild(ability, 80, {})).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the same legal talent-node set as the full catalog", () => {
|
||||
const talentNodeIds = new Set(Object.values(generatedData.classes)
|
||||
.flatMap((chains) => chains.flatMap((chain) => chain.talentNodeId ? [chain.talentNodeId] : [])));
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
ROGUE_DEADLY_POISON_DOSE_TAG,
|
||||
ROGUE_ENVENOM_BUFF_TAG,
|
||||
} from "./roguePoison";
|
||||
import { runtimeTalentNodeById } from "./talentRuntimeCatalog";
|
||||
import { baseResourcesFor } from "./wotlkStats";
|
||||
import {
|
||||
WOW335_WARLOCK_METAMORPHOSIS_FORM_ABILITIES,
|
||||
@@ -42,9 +43,6 @@ import { WOW335_TALENT_NODES } from "./wow335Talents.generated";
|
||||
import { WOW335_HUNTER_DETERRENCE_PARENT_AURA_EFFECTS } from "./wow335DeterrenceRuntime";
|
||||
import { WOW335_DETERRENCE_SOURCE_SIGNATURES } from "./wow335Deterrence";
|
||||
|
||||
// This generated source list is available synchronously when the abilities below are constructed.
|
||||
const SOURCE_WOW335_TALENT_NODE_IDS = new Set<string>(WOW335_TALENT_NODES.map((node) => node.id));
|
||||
|
||||
export const BASE_CLASS_STARTER_SOURCE_LEVEL = 10;
|
||||
|
||||
interface Wow335Effect {
|
||||
@@ -75,7 +73,6 @@ interface Wow335Rank {
|
||||
readonly description: string;
|
||||
readonly castTimeMs: number;
|
||||
readonly cooldownMs: number;
|
||||
readonly gcdCategoryId?: number;
|
||||
readonly gcdMs: number;
|
||||
readonly durationMs: number;
|
||||
readonly channelDurationMs: number;
|
||||
@@ -1636,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,
|
||||
@@ -1854,11 +1859,9 @@ export function hunterExplosiveTrapDirectScaling(
|
||||
};
|
||||
}
|
||||
|
||||
function hasAuraOrTriggeredSourceEffect(rank: Wow335Rank): boolean {
|
||||
return rank.effects.some((effect) => (
|
||||
(APPLY_AURA_EFFECTS.has(effect.effectType) && effect.auraType > 0)
|
||||
|| (TRIGGER_SPELL_EFFECTS.has(effect.effectType) && effect.triggerSpellId > 0)
|
||||
));
|
||||
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[] {
|
||||
@@ -2160,19 +2163,15 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
|
||||
}
|
||||
const auraOnlyStance = classId === "death-knight"
|
||||
&& ["Blood Presence", "Frost Presence", "Unholy Presence"].includes(abilityName);
|
||||
// Tooltip prose can describe later aura/proc payloads; DBC aura/trigger rows
|
||||
// are not evidence of direct damage or healing from the cast itself.
|
||||
const hasAuraOrTriggeredEffect = hasAuraOrTriggeredSourceEffect(rank);
|
||||
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") {
|
||||
effects.unshift({ kind: "damage", coefficient: 1 });
|
||||
} else if (!hasAuraOrTriggeredEffect && !/^Create\b/i.test(abilityName)
|
||||
} else if (!/^Create\b/i.test(abilityName)
|
||||
&& /\b(?:heal(?:s|ed|ing)?|restore(?:s|d)?|mend(?:s|ed)?|rejuvenat\w*)\b/i.test(rank.description)) {
|
||||
effects.unshift({ kind: "heal", coefficient: 1 });
|
||||
} else if (!hasAuraOrTriggeredEffect
|
||||
&& /\b(?:deals?|causes?|inflicts?)\b[^.]*\bdamage\b/i.test(rank.description)) {
|
||||
} else if (/\b(?:deals?|causes?|inflicts?)\b[^.]*\bdamage\b/i.test(rank.description)) {
|
||||
effects.unshift({ kind: "damage", coefficient: 1 });
|
||||
}
|
||||
}
|
||||
@@ -2181,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")
|
||||
@@ -2381,7 +2381,6 @@ function rankDefinition(
|
||||
: classId === "shaman" && abilityName === "Nature's Swiftness"
|
||||
? Math.max(180_000, rank.cooldownMs)
|
||||
: rank.cooldownMs,
|
||||
gcdCategoryId: rank.gcdCategoryId,
|
||||
gcdMs: rank.gcdMs,
|
||||
cost: {
|
||||
resource: rankResource,
|
||||
@@ -2538,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),
|
||||
);
|
||||
@@ -2591,18 +2591,18 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
|
||||
...(classicCooldownGroup(classId, chain.name)
|
||||
? { cooldownGroup: classicCooldownGroup(classId, chain.name) }
|
||||
: {}),
|
||||
gcdCategoryId: first.gcdCategoryId,
|
||||
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
|
||||
// Lash). They cannot be allocated by any legal local build, so expose
|
||||
// those retained class actions at their source level instead of making
|
||||
// them permanently unlearnable behind a dangling talent ID.
|
||||
talentNodeId: chain.talentNodeId && SOURCE_WOW335_TALENT_NODE_IDS.has(chain.talentNodeId)
|
||||
talentNodeId: chain.talentNodeId && runtimeTalentNodeById(chain.talentNodeId)
|
||||
? chain.talentNodeId
|
||||
: undefined,
|
||||
ranks: mappedRanks,
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"source":{"product":"World of Warcraft","build":"3.3.5a.12340","archive":"../wow335a/Data/enUS/patch-enUS.MPQ","spellTableRecords":46583,"generatedAt":"2026-10-06T16:30:57.969Z"},"classes":{"warrior":[{"id":"wow335-warrior-battle-shout","name":"Battle Shout","iconBasename":"ability_warrior_battleshout","iconPath":"/assets/ui/spells/ability_warrior_battleshout.png","sourceUnlockLevel":1,"ranks":[{"spellId":6673,"rank":1,"level":1,"sourceLevel":1,"maxScalingLevel":11,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 15. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"gcdCategoryId":133,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":-1,"equippedItemSubclassMask":0,"equippedItemInventoryMask":0,"wowSpellDefense":{"damageClass":1,"schoolMask":1,"attributes0":327696,"attributes1":0,"attributes3":0,"attributes5":0,"directDamageFromEffects":false,"sourceProfile":"wow335"},"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":15,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":15,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":5242,"rank":2,"level":12,"sourceLevel":12,"maxScalingLevel":21,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 35. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"gcdCategoryId":133,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":-1,"equippedItemSubclassMask":0,"equippedItemInventoryMask":0,"wowSpellDefense":{"damageClass":1,"schoolMask":1,"attributes0":327696,"attributes1":0,"attributes3":0,"attributes5":0,"directDamageFromEffects":false,"sourceProfile":"wow335"},"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":35,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":35,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":6192,"rank":3,"level":22,"sourceLevel":22,"maxScalingLevel":31,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 55. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"gcdCategoryId":133,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":-1,"equippedItemSubclassMask":0,"equippedItemInventoryMask":0,"wowSpellDefense":{"damageClass":1,"schoolMask":1,"attributes0":327696,"attributes1":0,"attributes3":0,"attributes5":0,"directDamageFromEffects":false,"sourceProfile":"wow335"},"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":55,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":55,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":11549,"rank":4,"level":32,"sourceLevel":32,"maxScalingLevel":41,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 85. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"gcdCategoryId":133,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":-1,"equippedItemSubclassMask":0,"equippedItemInventoryMask":0,"wowSpellDefense":{"damageClass":1,"schoolMask":1,"attributes0":327696,"attributes1":0,"attributes3":0,"attributes5":0,"directDamageFromEffects":false,"sourceProfile":"wow335"},"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":85,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":85,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":11550,"rank":5,"level":42,"sourceLevel":42,"maxScalingLevel":51,"description":"TheLine truncated
|
||||
{"source":{"product":"World of Warcraft","build":"3.3.5a.12340","archive":"../wow335a/Data/enUS/patch-enUS.MPQ","spellTableRecords":46583,"generatedAt":"2026-10-06T16:30:57.969Z"},"classes":{"warrior":[{"id":"wow335-warrior-battle-shout","name":"Battle Shout","iconBasename":"ability_warrior_battleshout","iconPath":"/assets/ui/spells/ability_warrior_battleshout.png","sourceUnlockLevel":1,"ranks":[{"spellId":6673,"rank":1,"level":1,"sourceLevel":1,"maxScalingLevel":11,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 15. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":-1,"equippedItemSubclassMask":0,"equippedItemInventoryMask":0,"wowSpellDefense":{"damageClass":1,"schoolMask":1,"attributes0":327696,"attributes1":0,"attributes3":0,"attributes5":0,"directDamageFromEffects":false,"sourceProfile":"wow335"},"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":15,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":15,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":5242,"rank":2,"level":12,"sourceLevel":12,"maxScalingLevel":21,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 35. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":-1,"equippedItemSubclassMask":0,"equippedItemInventoryMask":0,"wowSpellDefense":{"damageClass":1,"schoolMask":1,"attributes0":327696,"attributes1":0,"attributes3":0,"attributes5":0,"directDamageFromEffects":false,"sourceProfile":"wow335"},"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":35,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":35,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":6192,"rank":3,"level":22,"sourceLevel":22,"maxScalingLevel":31,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 55. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":-1,"equippedItemSubclassMask":0,"equippedItemInventoryMask":0,"wowSpellDefense":{"damageClass":1,"schoolMask":1,"attributes0":327696,"attributes1":0,"attributes3":0,"attributes5":0,"directDamageFromEffects":false,"sourceProfile":"wow335"},"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":55,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":55,"dieSides":1,"pointsPerLevel":0.5,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":11549,"rank":4,"level":32,"sourceLevel":32,"maxScalingLevel":41,"description":"The warrior shouts, increasing attack power of all raid and party members within the listed amount yards by 85. Lasts 120 sec.","castTimeMs":0,"cooldownMs":0,"gcdMs":1500,"durationMs":120000,"channelDurationMs":0,"powerType":1,"powerCost":100,"powerCostPercentage":0,"rangeIndex":1,"rangeMin":0,"rangeMax":0,"equippedItemClass":-1,"equippedItemSubclassMask":0,"equippedItemInventoryMask":0,"wowSpellDefense":{"damageClass":1,"schoolMask":1,"attributes0":327696,"attributes1":0,"attributes3":0,"attributes5":0,"directDamageFromEffects":false,"sourceProfile":"wow335"},"effects":[{"index":0,"effectType":6,"auraType":99,"basePoints":85,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0},{"index":1,"effectType":6,"auraType":124,"basePoints":85,"dieSides":1,"pointsPerLevel":1,"mechanic":0,"implicitTargetA":56,"implicitTargetB":0,"radius":30,"periodMs":0,"miscValue":0,"triggerSpellId":0,"coefficient":0}]},{"spellId":11550,"rank":5,"level":42,"sourceLevel":42,"maxScalingLevel":51,"description":"The warrior shouts, increasing attack power of all raid and party members within thLine truncated
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -1,225 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { commitPartyAbilityUse, partyAbilityIsReady } from "./partyAbilityAi";
|
||||
import {
|
||||
abilityAtLevel,
|
||||
abilitiesForClass,
|
||||
preloadAbilityCatalogsForClasses,
|
||||
} from "./abilityCatalog";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { createConfiguredPartyMember } from "./partyStore";
|
||||
import {
|
||||
abilityGcdCategory,
|
||||
abilityGlobalCooldownActive,
|
||||
globalCooldownForAbility,
|
||||
startAbilityGlobalCooldown,
|
||||
type GlobalCooldownActor,
|
||||
} from "./wowGlobalCooldown";
|
||||
|
||||
const paladinAbility = (name: string) => {
|
||||
const ability = abilitiesForClass("paladin").find((candidate) => candidate.name === name);
|
||||
if (!ability) throw new Error(`Missing source Paladin ability: ${name}`);
|
||||
return ability;
|
||||
};
|
||||
|
||||
describe("source-derived WoW global cooldown categories", () => {
|
||||
it.each([
|
||||
{ level: 74, spellId: 48941, categoryId: 38 },
|
||||
{ level: 78, spellId: 48941, categoryId: 38 },
|
||||
{ level: 79, spellId: 48942, categoryId: 133 },
|
||||
{ level: 80, spellId: 48942, categoryId: 133 },
|
||||
])("selects Devotion Aura source spell $spellId and category $categoryId at level $level", ({ level, spellId, categoryId }) => {
|
||||
const selected = abilityAtLevel(paladinAbility("Devotion Aura"), level);
|
||||
expect(selected.dbcSpellId).toBe(spellId);
|
||||
expect(selected.gcdCategoryId).toBe(categoryId);
|
||||
expect(selected.gcdMs).toBe(1_500);
|
||||
});
|
||||
|
||||
it("keeps independent source categories while projecting category 133 to the legacy scalar", () => {
|
||||
const devotion = abilityAtLevel(paladinAbility("Devotion Aura"), 74);
|
||||
const blessing = abilityAtLevel(paladinAbility("Blessing of Might"), 74);
|
||||
let actor: GlobalCooldownActor = {
|
||||
statRuleset: "wow",
|
||||
globalCooldownEndsAt: 0,
|
||||
globalCooldownEndsByCategory: {},
|
||||
};
|
||||
|
||||
actor = { ...actor, ...startAbilityGlobalCooldown(actor, devotion, 1_000, 1_500) };
|
||||
expect(actor.globalCooldownEndsAt).toBe(0);
|
||||
expect(actor.globalCooldownEndsByCategory?.["38"]).toBe(2_500);
|
||||
expect(globalCooldownForAbility(actor, blessing)).toBe(0);
|
||||
expect(abilityGlobalCooldownActive(actor, blessing, 1_001)).toBe(false);
|
||||
|
||||
actor = { ...actor, ...startAbilityGlobalCooldown(actor, blessing, 1_100, 1_500) };
|
||||
expect(actor.globalCooldownEndsByCategory).toEqual({ "38": 2_500, "133": 2_600 });
|
||||
expect(actor.globalCooldownEndsAt).toBe(2_600);
|
||||
expect(abilityGlobalCooldownActive(actor, devotion, 1_200)).toBe(true);
|
||||
expect(abilityGlobalCooldownActive(actor, blessing, 1_200)).toBe(true);
|
||||
});
|
||||
|
||||
it("checks a populated category for zero-duration abilities without starting or overwriting a timer", () => {
|
||||
const zeroTime133 = {
|
||||
id: "fixture-zero-time-category-133",
|
||||
source: "wow335-progression" as const,
|
||||
gcdCategoryId: 133,
|
||||
gcdMs: 0,
|
||||
};
|
||||
const category38 = {
|
||||
id: "fixture-category-38",
|
||||
source: "wow335-progression" as const,
|
||||
gcdCategoryId: 38,
|
||||
gcdMs: 1_500,
|
||||
};
|
||||
const actor = {
|
||||
statRuleset: "wow",
|
||||
globalCooldownEndsAt: 2_500,
|
||||
globalCooldownEndsByCategory: { "38": 1_500, "133": 2_500 },
|
||||
};
|
||||
|
||||
expect(abilityGlobalCooldownActive(actor, zeroTime133, 2_000)).toBe(true);
|
||||
expect(abilityGlobalCooldownActive(actor, zeroTime133, 2_500)).toBe(false);
|
||||
expect(abilityGlobalCooldownActive(actor, category38, 2_000)).toBe(false);
|
||||
expect(startAbilityGlobalCooldown(actor, zeroTime133, 2_000, 0)).toEqual({
|
||||
globalCooldownEndsAt: 2_500,
|
||||
globalCooldownEndsByCategory: actor.globalCooldownEndsByCategory,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses category zero as its own real key and never aliases it to 133", () => {
|
||||
const categoryZero = {
|
||||
id: "fixture-positive-time-category-zero",
|
||||
source: "wow335-progression" as const,
|
||||
gcdCategoryId: 0,
|
||||
gcdMs: 1_500,
|
||||
};
|
||||
const zeroDurationCategoryZero = { ...categoryZero, gcdMs: 0 };
|
||||
const actor = { statRuleset: "wow", globalCooldownEndsAt: 0, globalCooldownEndsByCategory: {} };
|
||||
const next = { ...actor, ...startAbilityGlobalCooldown(actor, categoryZero, 500, 900) };
|
||||
|
||||
expect(abilityGcdCategory(actor, zeroDurationCategoryZero)).toBe(0);
|
||||
expect(next.globalCooldownEndsByCategory).toEqual({ "0": 1_400 });
|
||||
expect(next.globalCooldownEndsAt).toBe(0);
|
||||
expect(abilityGlobalCooldownActive(next, zeroDurationCategoryZero, 1_000)).toBe(true);
|
||||
expect(abilityGlobalCooldownActive(next, { ...categoryZero, gcdCategoryId: 133 }, 1_000)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the legacy WoW fallback while leaving Runewaker scalar timers unchanged", () => {
|
||||
const legacyPositive = { source: "wow335-progression" as const, gcdMs: 1_500 };
|
||||
const legacyZero = { source: "wow335-progression" as const, gcdMs: 0 };
|
||||
const wow = { statRuleset: "wow", globalCooldownEndsAt: 2_000, globalCooldownEndsByCategory: {} };
|
||||
expect(abilityGcdCategory(wow, legacyPositive)).toBe(133);
|
||||
expect(globalCooldownForAbility(wow, legacyPositive)).toBe(2_000);
|
||||
expect(abilityGcdCategory(wow, legacyZero)).toBeNull();
|
||||
expect(globalCooldownForAbility(wow, legacyZero)).toBe(0);
|
||||
|
||||
const romAbility = { source: "runewaker" as const, gcdCategoryId: 38, gcdMs: 700 };
|
||||
const rom = { statRuleset: "rom", globalCooldownEndsAt: 3_000, globalCooldownEndsByCategory: { "38": 8_000 } };
|
||||
expect(abilityGcdCategory(rom, romAbility)).toBeNull();
|
||||
expect(globalCooldownForAbility(rom, romAbility)).toBe(3_000);
|
||||
expect(startAbilityGlobalCooldown(rom, romAbility, 4_000, 700)).toEqual({
|
||||
globalCooldownEndsAt: 4_700,
|
||||
globalCooldownEndsByCategory: rom.globalCooldownEndsByCategory,
|
||||
});
|
||||
});
|
||||
|
||||
it("admits the real category-38 rank beside category 133, then enforces each timer", async () => {
|
||||
await preloadAbilityCatalogsForClasses(["paladin"]);
|
||||
const devotion = paladinAbility("Devotion Aura");
|
||||
const blessing = paladinAbility("Blessing of Might");
|
||||
|
||||
for (const level of [74, 78, 79, 80]) {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "paladin",
|
||||
level,
|
||||
role: "tank",
|
||||
specializationId: "paladin:protection",
|
||||
raceId: "human",
|
||||
gender: "male",
|
||||
talentRanks: {},
|
||||
inventory: [],
|
||||
equipment: {},
|
||||
});
|
||||
const selected = abilityAtLevel(devotion, level);
|
||||
const expectedCategory = level < 79 ? 38 : 133;
|
||||
const expectedSpell = level < 79 ? 48941 : 48942;
|
||||
expect(selected.dbcSpellId).toBe(expectedSpell);
|
||||
expect(selected.gcdCategoryId).toBe(expectedCategory);
|
||||
|
||||
const first = useCombatStore.getState().castAbility(devotion.id, { now: 1_000 });
|
||||
expect(first.ok, `Devotion Aura should cast at level ${level}: ${JSON.stringify(first)}`).toBe(true);
|
||||
let state = useCombatStore.getState();
|
||||
expect((state.globalCooldownEndsByCategory ?? {})[String(expectedCategory)]).toBe(2_500);
|
||||
expect(state.globalCooldownEndsAt).toBe(expectedCategory === 133 ? 2_500 : 0);
|
||||
|
||||
const secondId = level < 79 ? blessing.id : devotion.id;
|
||||
const second = state.castAbility(secondId, { now: 1_100 });
|
||||
if (level < 79) {
|
||||
expect(second.ok, "category 133 remains available while category 38 is active").toBe(true);
|
||||
state = useCombatStore.getState();
|
||||
expect((state.globalCooldownEndsByCategory ?? {})["38"]).toBe(2_500);
|
||||
expect((state.globalCooldownEndsByCategory ?? {})["133"]).toBe(2_600);
|
||||
expect(state.globalCooldownEndsAt).toBe(2_600);
|
||||
expect(state.castAbility(devotion.id, { now: 1_200 })).toMatchObject({ ok: false, reason: "global-cooldown" });
|
||||
} else {
|
||||
expect(second).toMatchObject({ ok: false, reason: "global-cooldown" });
|
||||
expect((useCombatStore.getState().globalCooldownEndsByCategory ?? {})["133"]).toBe(2_500);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps configured Paladin companions on independent source categories", async () => {
|
||||
await preloadAbilityCatalogsForClasses(["paladin"]);
|
||||
const devotion = paladinAbility("Devotion Aura");
|
||||
const blessing = paladinAbility("Blessing of Might");
|
||||
|
||||
for (const level of [74, 79]) {
|
||||
useCombatStore.getState().initializeCharacter({
|
||||
classId: "paladin",
|
||||
level,
|
||||
role: "tank",
|
||||
specializationId: "paladin:protection",
|
||||
raceId: "human",
|
||||
gender: "male",
|
||||
talentRanks: {},
|
||||
inventory: [],
|
||||
equipment: {},
|
||||
});
|
||||
const member = createConfiguredPartyMember({
|
||||
name: `Paladin GCD diagnostic ${level}`,
|
||||
classId: "paladin",
|
||||
raceId: "human",
|
||||
gender: "male",
|
||||
specialization: "Protection",
|
||||
specializationId: "paladin:protection",
|
||||
talentTreeId: "paladin:protection",
|
||||
talentRanks: {},
|
||||
role: "tank",
|
||||
}, level, 0, `issue-114-paladin-${level}`);
|
||||
const selectedDevotion = abilityAtLevel(devotion, level);
|
||||
const selectedBlessing = abilityAtLevel(blessing, level);
|
||||
|
||||
expect(partyAbilityIsReady(member, selectedDevotion, 1_000)).toBe(true);
|
||||
const afterDevotion = commitPartyAbilityUse(member, selectedDevotion, 1_000);
|
||||
expect(afterDevotion).not.toBeNull();
|
||||
if (!afterDevotion) throw new Error("The ready Devotion Aura should commit for the configured Paladin.");
|
||||
|
||||
if (level === 74) {
|
||||
expect(afterDevotion.globalCooldownEndsByCategory?.["38"]).toBe(2_500);
|
||||
expect(afterDevotion.globalCooldownEndsAt).toBe(0);
|
||||
expect(partyAbilityIsReady(afterDevotion, selectedBlessing, 1_100),
|
||||
"category 133 is ready while only category 38 is active").toBe(true);
|
||||
const afterBlessing = commitPartyAbilityUse(afterDevotion, selectedBlessing, 1_100);
|
||||
expect(afterBlessing?.globalCooldownEndsByCategory).toEqual({ "38": 2_500, "133": 2_600 });
|
||||
expect(afterBlessing?.globalCooldownEndsAt).toBe(2_600);
|
||||
expect(afterBlessing).not.toBeNull();
|
||||
if (!afterBlessing) throw new Error("Category 133 readiness should permit Blessing of Might after category 38.");
|
||||
expect(partyAbilityIsReady(afterBlessing, selectedDevotion, 1_200)).toBe(false);
|
||||
expect(commitPartyAbilityUse(afterBlessing, selectedDevotion, 1_200)).toBeNull();
|
||||
} else {
|
||||
expect(afterDevotion.globalCooldownEndsByCategory?.["133"]).toBe(2_500);
|
||||
expect(afterDevotion.globalCooldownEndsAt).toBe(2_500);
|
||||
expect(partyAbilityIsReady(afterDevotion, selectedBlessing, 1_100)).toBe(false);
|
||||
expect(commitPartyAbilityUse(afterDevotion, selectedBlessing, 1_100)).toBeNull();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
export interface GlobalCooldownActor {
|
||||
readonly statRuleset?: string;
|
||||
readonly globalCooldownEndsAt: number;
|
||||
readonly globalCooldownEndsByCategory?: Readonly<Record<string, number>>;
|
||||
}
|
||||
|
||||
export interface GlobalCooldownAbility {
|
||||
readonly source?: string;
|
||||
readonly gcdCategoryId?: number;
|
||||
readonly gcdMs: number;
|
||||
}
|
||||
|
||||
function isRunewakerActor(actor: Pick<GlobalCooldownActor, "statRuleset">, ability: Pick<GlobalCooldownAbility, "source">): boolean {
|
||||
return actor.statRuleset === "rom" || ability.source === "runewaker";
|
||||
}
|
||||
|
||||
function hasCategory(value: number | undefined): value is number {
|
||||
return value !== undefined && Number.isSafeInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
/** Return the matching WotLK recovery category, or null for scalar-only behavior. */
|
||||
export function abilityGcdCategory(
|
||||
actor: Pick<GlobalCooldownActor, "statRuleset">,
|
||||
ability: GlobalCooldownAbility,
|
||||
): number | null {
|
||||
if (isRunewakerActor(actor, ability)) return null;
|
||||
if (hasCategory(ability.gcdCategoryId)) return ability.gcdCategoryId;
|
||||
return ability.gcdMs > 0 ? 133 : null;
|
||||
}
|
||||
|
||||
/** Return the active end time for this ability's category or legacy scalar timer. */
|
||||
export function globalCooldownForAbility(actor: GlobalCooldownActor, ability: GlobalCooldownAbility): number {
|
||||
if (isRunewakerActor(actor, ability)) return ability.gcdMs > 0 ? actor.globalCooldownEndsAt : 0;
|
||||
|
||||
const category = abilityGcdCategory(actor, ability);
|
||||
if (category === null) return 0;
|
||||
const key = String(category);
|
||||
const categorizedEnd = actor.globalCooldownEndsByCategory?.[key];
|
||||
if (categorizedEnd !== undefined) return categorizedEnd;
|
||||
return category === 133 ? actor.globalCooldownEndsAt : 0;
|
||||
}
|
||||
|
||||
export function abilityGlobalCooldownActive(
|
||||
actor: GlobalCooldownActor,
|
||||
ability: GlobalCooldownAbility,
|
||||
now: number,
|
||||
): boolean {
|
||||
return globalCooldownForAbility(actor, ability) > now;
|
||||
}
|
||||
|
||||
/** Start a category timer without changing unrelated categories or RoM state. */
|
||||
export function startAbilityGlobalCooldown(
|
||||
actor: GlobalCooldownActor,
|
||||
ability: GlobalCooldownAbility,
|
||||
now: number,
|
||||
durationMs: number,
|
||||
): Pick<GlobalCooldownActor, "globalCooldownEndsAt" | "globalCooldownEndsByCategory"> {
|
||||
if (!(durationMs > 0) || !Number.isFinite(durationMs)) {
|
||||
return {
|
||||
globalCooldownEndsAt: actor.globalCooldownEndsAt,
|
||||
...(actor.globalCooldownEndsByCategory
|
||||
? { globalCooldownEndsByCategory: actor.globalCooldownEndsByCategory }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (isRunewakerActor(actor, ability)) {
|
||||
return {
|
||||
globalCooldownEndsAt: now + durationMs,
|
||||
...(actor.globalCooldownEndsByCategory
|
||||
? { globalCooldownEndsByCategory: actor.globalCooldownEndsByCategory }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
const category = abilityGcdCategory(actor, ability);
|
||||
if (category === null) {
|
||||
return {
|
||||
globalCooldownEndsAt: actor.globalCooldownEndsAt,
|
||||
...(actor.globalCooldownEndsByCategory
|
||||
? { globalCooldownEndsByCategory: actor.globalCooldownEndsByCategory }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
const endTime = now + durationMs;
|
||||
return {
|
||||
globalCooldownEndsAt: category === 133 ? endTime : actor.globalCooldownEndsAt,
|
||||
globalCooldownEndsByCategory: {
|
||||
...actor.globalCooldownEndsByCategory,
|
||||
[String(category)]: endTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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