Compare commits

...
Author SHA1 Message Date
warren 2f8cb8a510 fix: allow explicit WoW Warrior off-hand swings 2026-10-15 09:26:01 -04:00
Codex Bot 9be6f54c52 fix: record control driven cast cancellations (#16) 2026-10-15 09:12:08 -04:00
warren 6a716fb8ca fix: preserve broad target-state talent scopes (#20) 2026-10-15 09:10:55 -04:00
warren b3e0445e9c Fix named talent cooldown scopes (#20)
Preserve named cooldown targets from tooltip clauses and keep named damage and healing effects scoped to their described abilities. Broad spell cooldowns remain broad.

Issue: phenom/healer-man#20
Coordinator: Root (/root), session 01a12399-cfab-7c72-8455-e0e4cb7e156c
Worker: /root/combat_talents_luna, GPT-6 Luna xhigh
Branch: fix/20-named-talent-scope
2026-10-15 09:10:55 -04:00
warren 72a67ac1e3 Fix benchmark maintained aura selection (#18) 2026-10-15 09:10:55 -04:00
Codex Bot 89b6f4f79d fix: label player auto attack damage events (#15) 2026-10-15 09:10:20 -04:00
warren 3687c423e2 Correct minion sacrifice and passive talent semantics (refs #19)
Coordinator Root /root, session 01a12399-cfab-7c72-8455-e0e4cb7e156c.
Independently reviewed by /root/combat_source_finish_luna (GPT-6 Luna xhigh).
31 focused DK tests, required release check and complete source type check pass.
2026-10-15 09:09:45 -04:00
Codex Bot a9322a3897 Merge pull request 'Exclude AppleDouble companions from test discovery (#11)' (#14) from fix/11-ignore-appledouble-inputs into main
Exclude AppleDouble test inputs and retain explicit compiler policy; refs #11.
2026-10-10 11:17:10 -07:00
warren 768a3275ea Exclude AppleDouble companions from test discovery (#11) 2026-10-10 14:17:08 -04:00
Codex Bot 812162320d Merge pull request 'Guard source gear namespaces and retain recoverable legacy records (#9)' (#13) from fix/9-game-gear-namespaces into main
Integrate tested gear namespace checkpoint; retain browser verification and native #124 remaining scope.
2026-10-10 08:36:57 -07:00
26 changed files with 1654 additions and 35 deletions

No files matched your search

+14
View File
@@ -5765,3 +5765,17 @@ result is claimed. The source browser build stops at missing ignored Manastorm
assets; browser release/visual verification remains open. Native offline gear
persistence, recovery presentation and authenticated RoM progression remain
under #124. See docs/GAME_GEAR_NAMESPACES.md and gear-namespace-validation.json.
## AppleDouble input discovery — shared #11 (2026-10-10)
Vitest defaults now exclude ._-prefixed file/directory components while retaining
its built-in exclusions. A portable fresh-fixture proof reproduces three binary
sidecar transform failures in the unfiltered control; fixed defaults run both
ordinary sentinels, including ordinary._fragment.test.ts. TypeScript API/CLI
retain those two inputs. TypeScript 5.8 already skipped dot-prefixed roots, so
its explicit exclusions preserve observed behavior. Vite/Node config typecheck
passes. See docs/METADATA_DISCOVERY.md and metadata-discovery-validation.json.
This is source tooling; no native runtime install or new dungeon entry was
performed. No observed Cellblock Rush boss, GPU capture, full app build,
gameplay or balance certification is claimed. Existing metadata is retained.
@@ -0,0 +1,37 @@
# Death Pact and Blood Gorged finding corrections
Tracked in [shared issue #19](https://git.whoagland.com/phenom/healer-man/issues/19).
Coordinator: Root `/root`, session `01a12399-cfab-7c72-8455-e0e4cb7e156c`.
The local Death Pact row (48743) supplies a 40% heal, a 100-yard minion-selection
radius, a 40 runic-power cost, a 120-second cooldown and a 1.5-second GCD.
Its hostile instant-kill conversion and tooltip-derived flat heal were wrong.
The explicit sacrifice effect now consumes exactly one living, unexpired undead
minion owned by the caster inside that radius and heals only that caster.
The heal cannot crit. Missing minions fail before resource, cooldown or GCD use.
Sacrifice produces a death record rather than damage-meter or experience credit.
Player and companion dispatch share minion selection and death-state mutation.
The [AzerothCore Death Pact handler](https://github.com/azerothcore/azerothcore-wotlk/blob/master/src/server/scripts/Spells/spell_dk.cpp)
corroborates living, owned undead selection within 100 yards and a single victim.
This runtime admits only the two undead ghoul entries it already models through
Raise Dead (26125) and Army of the Dead (24207). Unknown summon families are not
assumed undead. Other undead minion types require a modeled source identity.
Automated non-healer sacrifices use the existing defensive health threshold;
this is simulator AI policy, not a spell-source trigger.
Blood Gorged's five local ranks contain only learn-spell effects (36), linking
the conditional damage and armor-penetration children. The catalog no longer
invents an active attack from that passive tooltip. Talent wrappers with only
learn effects and no activation cost, cast time or cooldown remain passive;
active and mixed-effect spells remain executable. The generator preserves the
same classification for future imports. Existing sourced Blood Gorged damage
and armor handling remains in `wow335DeathKnightBloodGorgedRuntime.ts`; see
`docs/WOW_DEATH_KNIGHT_BLOOD_GORGED.md` for its health and family-mask boundaries.
Focused validation covers real Raise Dead then Death Pact casts, hostile trash
and bosses, owner isolation, dead/expired/distant/non-undead minions, exact
cost/cooldown/GCD, overheal and noncritical event accounting, actual automatic
companion dispatch, and all five Blood Gorged wrapper ranks. Existing player and
companion Blood Gorged health/mask tests preserve the legitimate passive bonus.
The historical 873-cell campaign and source snapshots are unchanged.
@@ -0,0 +1,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
```
+53
View File
@@ -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.
+41
View File
@@ -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"
]
}
+5 -1
View File
@@ -396,7 +396,11 @@ try {
// with the same name on the class skill line. That proc has no player
// activation mechanics, so retain it for the spellbook but classify it
// as passive instead of exposing it as a castable action.
const passive = passiveTalentNames.has(name) && ranks.every((spell) => (
const passive = (passiveTalentNames.has(name) || (Boolean(talentNodeId)
&& ranks.every((spell) => {
const effects = spell.effects.filter((effect) => effect.effectType !== 0);
return effects.length > 0 && effects.every((effect) => effect.effectType === 36);
}))) && ranks.every((spell) => (
spell.castTimeMs === 0
&& spell.recoveryTimeMs === 0
&& spell.categoryRecoveryTimeMs === 0
+1
View File
@@ -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;
+1 -1
View File
@@ -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);
});
});
+17 -8
View File
@@ -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);
}
}
+2
View File
@@ -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;
+47
View File
@@ -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({
+53 -5
View File
@@ -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;
+1 -1
View File
@@ -1536,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 });
});
+152 -9
View File
@@ -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,
@@ -461,6 +462,7 @@ import { inventoryItemUsableForCategory, normalizeInventory, type InventoryItem
import {
PLAYER_EQUIPMENT_OWNER_ID,
activeEquipmentStatsForClass,
classCanEquipItem,
equipInventoryItem,
normalizeEquipment,
unequipInventorySlot,
@@ -1712,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;
@@ -1822,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"
> {
@@ -1908,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)];
}
@@ -1973,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(
@@ -2384,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"
@@ -7656,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) {
@@ -7745,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
@@ -9439,6 +9502,12 @@ function maybeMirrorDeathKnightDancingRuneWeaponMeleeInWork(
source,
school,
work.gearStats.spellPenetration,
undefined,
undefined,
false,
undefined,
undefined,
WOW335_DEATH_KNIGHT_DANCING_RUNE_WEAPON_ABILITY_ID,
);
}
@@ -9552,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) {
@@ -9919,6 +9989,7 @@ function resolveAndDamageMobInWork(
: sourceWeaponChannel === "off-hand" ? "offhand"
: sourceWeaponChannel === "ranged" ? "ranged" : undefined
: undefined,
reportedAbilityId,
);
maybeProcShamanFlurryInWork(
work,
@@ -13665,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-")
@@ -16879,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];
@@ -18676,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();
@@ -20568,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;
@@ -21452,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") {
@@ -21513,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, {
@@ -21724,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
@@ -21748,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
@@ -24337,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;
@@ -24407,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
+16 -2
View File
@@ -1,3 +1,4 @@
import { eligibleSacrificeMinion } from "./sacrificeMinion";
import { healerManaRecoveryAmount } from "./healerManaRecovery";
import { nativeDisarmedMainHandAutoAttackProfile, nativeTrainingAutoAttackProfile } from "./romNativeCombatAmount";
import { romNativePlayerHitProfile } from "./romNativeCombatResolution";
@@ -2666,6 +2667,8 @@ export function partyAbilityTargetIsValid(
|| (metamorphosisActive && !warlockMetamorphosisCanCastAbilityWhileActive(ability))) return false;
}
const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition;
if (ability.effects.some((effect) => effect.kind === "sacrifice-minion"
&& !eligibleSacrificeMinion(combat.summons, member.id, effect, origin, now))) return false;
const meditationAllowsMovement = romMageWarriorMeditationPathAllowsMovement(
member.classId,
member.secondaryClassId,
@@ -3190,7 +3193,7 @@ function choosePartySupportIntent(
lowestHealthFraction: target.health / Math.max(1, target.maxHealth),
friendlyTargets,
maintainedAbilityIds,
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield"].includes(effect.kind)
canUseAbility: ability => ability.effects.some(effect => ["heal", "hot", "shield", "sacrifice-minion"].includes(effect.kind)
|| (effect.kind === "apply-aura" && Boolean(effect.aura.absorbs?.length)))
&& (healingAbilityCanAidOthers(ability) || target.id === member.id)
&& !(healingAbilityHasPendingHot(ability, member.id, target.id, combat.effects)
@@ -4587,6 +4590,17 @@ function executePartyAbility(
ability.id,
now,
);
} else if (effect.kind === "sacrifice-minion") {
if (!useCombatStore.getState().sacrificeActorMinion(member.id, ability.id, effect, origin, now)) continue;
const result = useCombatStore.getState().healActor(member.id,
Math.round(member.maxHealth * effect.percentMaxHealth), {
sourceActorId: member.id, abilityId: ability.id, school: spellSchool,
criticalChance: 0, now,
});
effectiveAmount += result.effectiveAmount;
directHealingRawAmount += result.rawAmount;
combat = useCombatStore.getState();
runtimeMember = memberFromCompanionActor(runtimeMember, combat.actors[member.id]);
} else if (effect.kind === "execute") {
for (const hostileTargetId of hostileTargets) {
const target = useCombatStore.getState().mobs[hostileTargetId];
@@ -5147,7 +5161,7 @@ function executePartyAbility(
}
emitPartyAbility(member, ability, targetId, "release", now, !castStartAlreadyEmitted);
emitPartyAbility(member, ability, targetId, "impact", now, !castStartAlreadyEmitted);
if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection"].includes(effect.kind))) {
if (effectiveAmount > 0 && ability.effects.some((effect) => ["heal", "hot", "shield", "resurrection", "sacrifice-minion"].includes(effect.kind))) {
useCombatStore.getState().addHealingThreat({ actorId: member.id, role: member.role, abilityId: ability.id }, effectiveAmount);
}
if (suddenDoomTrigger && depth === 0) {
+286
View File
@@ -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
View File
@@ -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);
});
});
+18
View File
@@ -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);
}
+66
View File
@@ -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")!;
+26 -2
View File
@@ -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);
+20 -4
View File
@@ -1633,6 +1633,14 @@ function classScriptEffects(
triggerSpellId: 0,
durationMs: 0,
}];
case "death-knight:Death Pact": return [{
kind: "sacrifice-minion",
// These are the undead ghoul entries already modeled by Raise Dead and
// Army of the Dead. Unknown summon families are never assumed undead.
creatureIds: [26125, 24207],
radius: rank.effects.find((effect) => effect.effectType === 1)?.radius ?? 0,
percentMaxHealth: Math.abs(rank.effects.find((effect) => effect.effectType === 10)?.basePoints ?? 0) / 100,
}];
case "death-knight:Raise Dead": return [{ kind: "summon", creatureId: 26125, coefficient: 0.45, durationMs: 60_000 }];
case "death-knight:Army of the Dead": return Array.from({ length: 8 }, () => ({
kind: "summon" as const, creatureId: 24207, coefficient: 0.2, durationMs: 40_000,
@@ -1851,6 +1859,11 @@ export function hunterExplosiveTrapDirectScaling(
};
}
function learnOnlyRank(rank: Wow335Rank): boolean {
const effects = rank.effects.filter((effect) => effect.effectType !== 0);
return effects.length > 0 && effects.every((effect) => effect.effectType === 36);
}
function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: string): readonly AbilityEffect[] {
const effects: AbilityEffect[] = [];
for (const [effectIndex, effect] of rank.effects.entries()) {
@@ -2150,7 +2163,7 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
}
const auraOnlyStance = classId === "death-knight"
&& ["Blood Presence", "Frost Presence", "Unholy Presence"].includes(abilityName);
if (!auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand")
if (!learnOnlyRank(rank) && !auraOnlyStance && !(classId === "warrior" && abilityName === "Last Stand")
&& !(classId === "mage" && abilityName === "Molten Armor")
&& !effects.some((effect) => ["damage", "heal", "dot", "hot", "shield"].includes(effect.kind))) {
if (classId === "death-knight" && abilityName === "Death Coil") {
@@ -2167,6 +2180,7 @@ function effectsForRank(classId: BaseClassId, rank: Wow335Rank, abilityName: str
.filter((effect) => effect.kind !== "scripted" && effect.kind !== "unsupported")
.filter((effect) => !(
(classId === "paladin" && abilityName === "Redemption" && effect.kind === "resurrection")
|| (classId === "death-knight" && abilityName === "Death Pact" && ["execute", "heal"].includes(effect.kind))
|| (classId === "paladin" && abilityName === "Divine Storm"
&& (effect.kind === "damage" || effect.kind === "heal"))
|| (classId === "paladin" && abilityName === "Holy Shield" && effect.kind === "damage")
@@ -2523,8 +2537,9 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
const effectiveSourceUnlockLevel = classId === "warrior" && chain.name === "Sweeping Strikes"
? Math.max(1, firstRaw.sourceLevel || firstRaw.level)
: chain.sourceUnlockLevel;
const target = abilityNameTarget(classId, chain.name) ?? targetForRank(firstRaw, first.effects);
const radius = Math.max(
const target = first.effects.some((effect) => effect.kind === "sacrifice-minion")
? "self" : abilityNameTarget(classId, chain.name) ?? targetForRank(firstRaw, first.effects);
const radius = first.effects.some((effect) => effect.kind === "sacrifice-minion") ? 0 : Math.max(
abilityNameRadius(chain.name),
...firstRaw.effects.map((effect) => effect.radius),
);
@@ -2579,7 +2594,8 @@ function abilityDefinition(classId: BaseClassId, chain: Wow335Chain): AbilityDef
gcdMs: first.gcdMs,
cost: first.cost,
effects: first.effects,
passive: chain.passive,
passive: chain.passive || (Boolean(chain.talentNodeId) && castRanks.every((rank) => learnOnlyRank(rank) && rank.castTimeMs === 0
&& rank.cooldownMs === 0 && rank.powerCost === 0 && rank.powerCostPercentage === 0)),
source: "wow335-progression",
// Some stock WotLK active talents remain in Spell.dbc after Ascension's
// patched Talent.dbc removes their nodes (Devastate, Shadowstep, Lava
@@ -0,0 +1,125 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { abilitiesForCharacter, abilityAtLevel, type AbilityEffect } from "./abilityCatalog";
import { PLAYER_AGGRO_ID } from "./aggro";
import { installCombatClock } from "./combatClock";
import { useCombatStore } from "./combatStore";
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
import { advanceDungeonPartyCombat, partyAbilityTargetIsValid, resetPartyRuntimeClock } from "./partyRuntime";
import { clearPartyRuntimeRegistry, registerPartyRuntimePosition } from "./partyRuntimeRegistry";
import { createConfiguredPartyMember, usePartyStore } from "./partyStore";
import { EMPTY_ITEM_STATS } from "./itemStats";
const PACT = "wow335-death-knight-death-pact";
const RAISE = "wow335-death-knight-raise-dead";
const ability = (id: string) => abilityAtLevel(abilitiesForCharacter("death-knight", null).find(a => a.id === id)!, 80);
let restoreClock: (() => void) | undefined;
function hostile(id: string, boss = false) {
useCombatStore.getState().registerMob(id, { name: id, maxHealth: 100_000, xpReward: 0, boss });
setMobPosition(id, [2, 0, 0]);
useCombatStore.getState().selectMob(id);
}
function summon(owner = PLAYER_AGGRO_ID, position: readonly [number, number, number] = [0, 0, 0], creatureId = 26125) {
const source = ability(RAISE);
const effect = source.effects.find(e => e.kind === "summon") as Extract<AbilityEffect, {kind: "summon"}>;
return useCombatStore.getState().summonActor(owner, source, { ...effect, creatureId }, position, null, 1_000)!;
}
describe("Death Pact source semantics and learned Blood Gorged", () => {
beforeEach(() => {
restoreClock = installCombatClock(() => 1_000);
clearMobRuntimeRegistry(); clearPartyRuntimeRegistry(); resetPartyRuntimeClock();
useCombatStore.getState().initializeCharacter({ classId: "death-knight", level: 80 });
usePartyStore.setState({ members: [], command: "attack" });
useCombatStore.setState({ health: 200, maxHealth: 1_000, resource: 100,
resourcePools: { "runic-power": 100 }, gearStats: { ...EMPTY_ITEM_STATS, criticalStrikeRating: 100_000 } });
useCombatStore.getState().synchronizeActors();
});
afterEach(() => restoreClock?.());
it("does not spend resources, cooldown or GCD without a living owned minion", () => {
hostile("trash"); hostile("boss", true);
const result = useCombatStore.getState().castAbility(PACT, undefined, 2_000);
expect(result).toMatchObject({ ok: false, reason: "condition-not-met", damage: 0, healing: 0 });
const s = useCombatStore.getState();
expect(s.resource).toBe(100); expect(s.cooldowns[PACT]).toBeUndefined();
expect(s.globalCooldownEndsAt).toBe(0);
expect(s.mobs.trash.health).toBe(100_000); expect(s.mobs.boss.health).toBe(100_000);
});
it("consumes one actual Raise Dead minion, heals only its caster for 40%, and never crits", () => {
hostile("trash"); hostile("boss", true);
expect(useCombatStore.getState().castAbility(RAISE, undefined, 1_000).ok).toBe(true);
const first = useCombatStore.getState().summons.find(s => s.creatureId === 26125)!;
expect(first).toBeDefined();
const other = summon();
const before = useCombatStore.getState();
const result = before.castAbility(PACT, undefined, 3_000);
expect(result).toMatchObject({ ok: true, damage: 0, healing: 400 });
const s = useCombatStore.getState();
expect(s.health).toBe(600); expect(s.resource).toBe(60);
expect(s.cooldowns[PACT]).toBe(123_000); expect(s.globalCooldownEndsAt).toBe(4_500);
expect(s.summons.find(x => x.id === first.id)?.health).toBe(0);
expect(s.actors[first.id]).toMatchObject({ health: 0, alive: false });
expect(s.summons.find(x => x.id === other)?.health).toBeGreaterThan(0);
expect(s.mobs.trash.health).toBe(100_000); expect(s.mobs.boss.health).toBe(100_000);
const events = s.combatEvents.filter(e => e.abilityId === PACT);
expect(events.filter(e => e.kind === "damage")).toHaveLength(0);
expect(events.filter(e => e.kind === "death")).toHaveLength(1);
expect(events.filter(e => e.kind === "healing")).toEqual([expect.objectContaining({
targetActorId: PLAYER_AGGRO_ID, rawAmount: 400, effectiveAmount: 400, critical: false,
})]);
});
it.each(["dead", "expired", "distant", "living-family"])("rejects %s minions and does not heal", mode => {
const id = summon(PLAYER_AGGRO_ID, mode === "distant" ? [101, 0, 0] : [0, 0, 0], mode === "living-family" ? 29264 : 26125);
useCombatStore.setState(s => ({ summons: s.summons.map(x => x.id === id
? { ...x, ...(mode === "dead" ? {health: 0} : {}), ...(mode === "expired" ? {expiresAt: 2_000} : {}) } : x) }));
expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000)).toMatchObject({ ok: false, reason: "condition-not-met" });
expect(useCombatStore.getState().health).toBe(200);
});
it("does not consume another caster's minion; companion automatic use heals only itself", () => {
const base = createConfiguredPartyMember({ name: "DK", classId: "death-knight", specialization: "Blood",
specializationId: "death-knight:blood", talentTreeId: "death-knight-blood", role: "damage" }, 80, 0, "pact-party");
const member = { ...base, health: 100, maxHealth: 1_000, resource: 100,
resourcePools: {"runic-power": 100}, gearStats: EMPTY_ITEM_STATS, nextActionAt: 0,
talentRanks: {}, cooldowns: Object.fromEntries(abilitiesForCharacter("death-knight", null)
.filter(a => a.id !== PACT).map(a => [a.id, 20_000])) };
usePartyStore.setState({ members: [member], command: "attack" });
registerPartyRuntimePosition(member.id, [0, 0, 0]);
useCombatStore.getState().synchronizeActors();
const id = summon(member.id);
expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000)).toMatchObject({ ok: false, reason: "condition-not-met" });
expect(partyAbilityTargetIsValid(member, ability(PACT), member.id, undefined, 2_000)).toBe(true);
hostile("party-trash");
useCombatStore.setState(s => ({ mobs: { ...s.mobs, "party-trash": { ...s.mobs["party-trash"], engaged: true } } }));
advanceDungeonPartyCombat(2_000);
const s = useCombatStore.getState();
expect(s.summons.find(x => x.id === id)?.health).toBe(0);
expect(s.health).toBe(200);
expect(usePartyStore.getState().members[0].health).toBe(500);
expect(s.combatEvents.filter(e => e.kind === "healing" && e.abilityId === PACT)).toEqual([
expect.objectContaining({ sourceActorId: member.id, targetActorId: member.id, rawAmount: 400, critical: false }),
]);
expect(partyAbilityTargetIsValid(member, ability(PACT), member.id, undefined, 2_001)).toBe(false);
});
it("caps overhealing and accounts for it once", () => {
summon(); useCombatStore.setState({ health: 950 });
expect(useCombatStore.getState().castAbility(PACT, undefined, 2_000).healing).toBe(50);
expect(useCombatStore.getState().combatEvents.find(e => e.kind === "healing" && e.abilityId === PACT))
.toMatchObject({ rawAmount: 400, effectiveAmount: 50, overheal: 350, critical: false });
});
it.each([1, 2, 3, 4, 5])("keeps Blood Gorged rank %s passive without an invented direct attack", rank => {
const source = ability("wow335-death-knight-blood-gorged");
expect(source.passive).toBe(true);
expect(source.ranks?.[rank - 1].effects.some(e => ["damage", "heal", "execute"].includes(e.kind))).toBe(false);
useCombatStore.setState({ talentRanks: { "talent-2034": rank } });
expect(useCombatStore.getState().castAbility(source.id, undefined, 2_000))
.toMatchObject({ ok: false, reason: "passive", damage: 0 });
});
});
@@ -0,0 +1,405 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import {
lstatSync,
mkdirSync,
readFileSync,
realpathSync,
writeFileSync,
} from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const METADATA_EXCLUDES = ["**/._*", "**/._*/**"];
const SENTINELS = ["src/metadata-discovery-sentinel.test.ts", "src/ordinary._fragment.test.ts"];
const SENTINEL = SENTINELS[0];
const METADATA_FILE = "src/._appledouble-file.test.ts";
const METADATA_NESTED_FILE = "src/._appledouble-directory/nested-metadata.test.ts";
function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
function sha256File(filePath) {
assertRegularNoSymlink(filePath);
return sha256(readFileSync(filePath));
}
function assertRegularNoSymlink(filePath) {
const stat = lstatSync(filePath);
assert.equal(stat.isSymbolicLink(), false, `symbolic link is not accepted: ${filePath}`);
assert.equal(stat.isFile(), true, `expected a regular file: ${filePath}`);
}
function assertNoSymlinkComponents(targetPath) {
const absolute = path.resolve(targetPath);
const parsed = path.parse(absolute);
let current = parsed.root;
for (const component of absolute.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
current = path.join(current, component);
try {
const stat = lstatSync(current);
assert.equal(stat.isSymbolicLink(), false, `symbolic path component is not accepted: ${current}`);
} catch (error) {
if (error.code === "ENOENT") break;
throw error;
}
}
}
function hasPathPrefix(parent, candidate) {
const relative = path.relative(parent, candidate);
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
}
function canonicalizeProspectivePath(targetPath) {
let current = path.resolve(targetPath);
const suffix = [];
while (true) {
try {
const canonicalAncestor = realpathSync(current);
return path.resolve(canonicalAncestor, ...suffix.reverse());
} catch (error) {
if (error.code !== "ENOENT") throw error;
const parent = path.dirname(current);
assert.notEqual(parent, current, `no existing ancestor for evidence path: ${targetPath}`);
suffix.push(path.basename(current));
current = parent;
}
}
}
function assertNoAppleDoublePathComponents(targetPath) {
const absolute = path.resolve(targetPath);
const parsed = path.parse(absolute);
const components = absolute.slice(parsed.root.length).split(path.sep).filter(Boolean);
for (const component of components) {
assert.equal(component.startsWith("._"), false, `AppleDouble path component is not accepted: ${component}`);
}
}
function assertFreshEvidenceDirectory(targetPath) {
assert.equal(path.isAbsolute(targetPath), true, "--evidence-dir must be absolute");
const absolute = path.resolve(targetPath);
assert.equal(absolute, targetPath, "--evidence-dir must be normalized");
assertNoAppleDoublePathComponents(absolute);
assertNoSymlinkComponents(absolute);
const canonicalProjectRoot = realpathSync(PROJECT_ROOT);
const canonicalEvidence = canonicalizeProspectivePath(absolute);
assert.equal(hasPathPrefix(canonicalProjectRoot, canonicalEvidence) || hasPathPrefix(canonicalEvidence, canonicalProjectRoot), false,
"evidence directory must not overlap the checkout");
try {
lstatSync(absolute);
assert.fail(`evidence directory already exists; use a fresh path: ${absolute}`);
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
mkdirSync(path.dirname(absolute), { recursive: true });
mkdirSync(absolute, { recursive: false });
return absolute;
}
function parseArgs(argv) {
const args = {};
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token !== "--evidence-dir" || !argv[index + 1]) {
throw new Error("usage: verify-appledouble-metadata-discovery.mjs --evidence-dir ABSOLUTE_FRESH_PATH");
}
if (args.evidenceDir) throw new Error("--evidence-dir may be provided once");
args.evidenceDir = argv[index + 1];
index += 1;
}
if (!args.evidenceDir || !path.isAbsolute(args.evidenceDir)) {
throw new Error("--evidence-dir must be an absolute path");
}
return args;
}
function runNode(args, cwd, stdoutPath, stderrPath) {
const result = spawnSync(process.execPath, args, {
cwd,
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
env: { ...process.env, CI: "1" },
});
writeFileSync(stdoutPath, result.stdout ?? "", "utf8");
writeFileSync(stderrPath, result.stderr ?? "", "utf8");
if (result.error) throw result.error;
return {
argv: [process.execPath, ...args],
cwd,
exitCode: result.status,
signal: result.signal,
stdout: { path: stdoutPath, sha256: sha256File(stdoutPath) },
stderr: { path: stderrPath, sha256: sha256File(stderrPath) },
error: result.error?.message ?? null,
};
}
function readGitHead() {
const result = spawnSync("git", ["rev-parse", "HEAD"], {
cwd: PROJECT_ROOT,
encoding: "utf8",
});
return result.status === 0 ? result.stdout.trim() : null;
}
function generatedTest(importPath, name, body) {
return `import { expect, it } from ${JSON.stringify(importPath)};\n\nit(${JSON.stringify(name)}, () => {\n${body}\n});\n`;
}
function readVitestFiles(jsonPath, fixtureRoot) {
const report = JSON.parse(readFileSync(jsonPath, "utf8"));
const testResults = report.testResults;
assert.ok(Array.isArray(testResults), "Vitest JSON reporter must provide testResults");
return testResults.map((result) => path.relative(fixtureRoot, result.name).split(path.sep).join("/")).sort();
}
function writeJson(filePath, value) {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
let evidenceRootForFailure = null;
async function main() {
const { evidenceDir } = parseArgs(process.argv.slice(2));
const absoluteEvidence = assertFreshEvidenceDirectory(evidenceDir);
evidenceRootForFailure = absoluteEvidence;
const reportPath = path.join(absoluteEvidence, "report.json");
const R = createRequire(path.join(PROJECT_ROOT, "package.json"));
const vitestRunner = R.resolve("vitest/vitest.mjs");
const vitestEntry = R.resolve("vitest/dist/index.js");
const vitestConfigEntry = R.resolve("vitest/dist/config.js");
const viteEntry = R.resolve("vite");
const tsEntry = R.resolve("typescript");
const vitestPackage = JSON.parse(readFileSync(path.join(PROJECT_ROOT, "node_modules", "vitest", "package.json"), "utf8"));
const vitePackage = JSON.parse(readFileSync(path.join(PROJECT_ROOT, "node_modules", "vite", "package.json"), "utf8"));
const tsPackage = JSON.parse(readFileSync(path.join(PROJECT_ROOT, "node_modules", "typescript", "package.json"), "utf8"));
const configInfo = {};
for (const name of ["vite.config.ts", "tsconfig.app.json"]) {
const actualPath = path.join(PROJECT_ROOT, name);
const actualHash = sha256File(actualPath);
configInfo[name] = { path: actualPath, sha256: actualHash };
}
const vite = await import(pathToFileURL(viteEntry).href);
const { configDefaults } = await import(pathToFileURL(vitestConfigEntry).href);
const loaded = await vite.loadConfigFromFile({ command: "serve", mode: "test" }, path.join(PROJECT_ROOT, "vite.config.ts"), PROJECT_ROOT);
assert.ok(loaded?.config, "Vite must load the actual project config");
const testConfig = loaded.config.test;
assert.deepEqual(testConfig.include, ["src/**/*.test.ts"], "candidate must preserve the normal Vitest include");
assert.ok(Array.isArray(testConfig.exclude), "project Vitest config must define exclude patterns");
for (const defaultExclude of configDefaults.exclude) {
assert.ok(testConfig.exclude.includes(defaultExclude), `Vitest built-in exclusion was dropped: ${defaultExclude}`);
}
for (const metadataExclude of METADATA_EXCLUDES) {
assert.ok(testConfig.exclude.includes(metadataExclude), `metadata exclusion is missing: ${metadataExclude}`);
}
const baselineTestConfig = {
...testConfig,
exclude: testConfig.exclude.filter((pattern) => !METADATA_EXCLUDES.includes(pattern)),
};
const fixtureRoot = path.join(absoluteEvidence, "fixture-project");
const srcRoot = path.join(fixtureRoot, "src");
mkdirSync(srcRoot, { recursive: true });
writeFileSync(path.join(srcRoot, "._appledouble-file.test.ts"),
generatedTest(vitestEntry, "AppleDouble file fixture is discoverable without the scoped exclusion", " expect(true).toBe(true);\n"), "utf8");
mkdirSync(path.join(srcRoot, "._appledouble-directory"), { recursive: true });
writeFileSync(path.join(srcRoot, "._appledouble-directory", "nested-metadata.test.ts"),
generatedTest(vitestEntry, "AppleDouble nested-directory fixture is discoverable without the scoped exclusion", " expect(true).toBe(true);\n"), "utf8");
writeFileSync(path.join(srcRoot, "metadata-discovery-sentinel.test.ts"),
generatedTest(vitestEntry, "ordinary source sentinel runs", " expect(2 + 2).toBe(4);\n"), "utf8");
writeFileSync(path.join(srcRoot, "ordinary._fragment.test.ts"),
generatedTest(vitestEntry, "ordinary filename containing dot underscore remains included", " expect(" +
"'ordinary._fragment.test.ts'.startsWith('._')).toBe(false);\n"), "utf8");
// This control is derived from the actual loaded config and removes only the two issue filters.
const baselineConfig = path.join(absoluteEvidence, "baseline-vitest.config.mjs");
writeFileSync(baselineConfig, `export default ${JSON.stringify({ test: baselineTestConfig }, null, 2)};\n`, "utf8");
const baselineJson = path.join(absoluteEvidence, "vitest-baseline-results.json");
const baseline = runNode([vitestRunner, "run", "--root", fixtureRoot, "--config", baselineConfig,
"--reporter=json", `--outputFile=${baselineJson}`, "--maxWorkers=1"], PROJECT_ROOT,
path.join(absoluteEvidence, "vitest-baseline.stdout.log"), path.join(absoluteEvidence, "vitest-baseline.stderr.log"));
const baselineFiles = readVitestFiles(baselineJson, fixtureRoot);
for (const sentinel of SENTINELS) {
assert.ok(baselineFiles.includes(sentinel), `baseline Vitest control must execute ordinary file: ${sentinel}`);
}
assert.ok(baselineFiles.includes(METADATA_FILE), "baseline control must discover the AppleDouble file fixture");
assert.ok(baselineFiles.includes(METADATA_NESTED_FILE), "baseline control must discover the nested AppleDouble fixture");
const baselineJsonResult = JSON.parse(readFileSync(baselineJson, "utf8"));
const baselineFailures = baselineJsonResult.testResults
.filter((result) => result.status === "failed")
.map((result) => ({
path: path.relative(fixtureRoot, result.name).split(path.sep).join("/"),
message: result.message,
}));
assert.ok(baseline.exitCode === 0 || baseline.exitCode === 1, "baseline must exit normally, not crash");
for (const failure of baselineFailures) {
assert.ok(path.posix.basename(failure.path).startsWith("._"),
`baseline may fail only while transforming an AppleDouble sidecar: ${failure.path}`);
assert.ok(failure.message.includes("Unexpected") && failure.message.includes("\\x00"),
`baseline AppleDouble transform failure should identify binary metadata: ${failure.path}`);
}
const candidateJson = path.join(absoluteEvidence, "vitest-candidate-results.json");
const candidate = runNode([vitestRunner, "run", "--root", fixtureRoot,
"--config", path.join(PROJECT_ROOT, "vite.config.ts"), "--mode", "test", "--reporter=json",
`--outputFile=${candidateJson}`, "--maxWorkers=1"], PROJECT_ROOT,
path.join(absoluteEvidence, "vitest-candidate.stdout.log"), path.join(absoluteEvidence, "vitest-candidate.stderr.log"));
assert.equal(candidate.exitCode, 0, "candidate Vitest discovery must pass");
const candidateFiles = readVitestFiles(candidateJson, fixtureRoot);
assert.deepEqual(candidateFiles, [...SENTINELS].sort(), "candidate discovery must keep ordinary names and exclude dot-prefix metadata");
const ts = R("typescript");
const tsconfigSource = path.join(PROJECT_ROOT, "tsconfig.app.json");
const tsconfigPath = path.join(fixtureRoot, "tsconfig.app.json");
const tsconfigBytes = readFileSync(tsconfigSource);
writeFileSync(tsconfigPath, tsconfigBytes);
const tsconfigJson = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
assert.equal(tsconfigJson.error, undefined, "TypeScript must read the copied app config");
const parsedTsconfig = ts.parseJsonConfigFileContent(tsconfigJson.config, ts.sys, fixtureRoot, undefined, tsconfigPath);
assert.deepEqual(parsedTsconfig.errors, [], "TypeScript config must parse without diagnostics");
assert.deepEqual(tsconfigJson.config.include, ["src"], "TypeScript include must retain normal src scope");
assert.deepEqual(tsconfigJson.config.compilerOptions.types, ["vite/client", "vitest/globals"],
"TypeScript configured test/browser globals must remain unchanged");
const tsFiles = parsedTsconfig.fileNames.map((filePath) => path.relative(fixtureRoot, filePath).split(path.sep).join("/")).sort();
assert.deepEqual(tsFiles, [...SENTINELS].sort(), "TypeScript must keep ordinary names and exclude dot-prefix metadata");
const expectedPackageExcludes = ["node_modules", "bower_components", "jspm_packages"];
for (const exclude of expectedPackageExcludes) {
assert.ok(tsconfigJson.config.exclude.includes(exclude), `TypeScript package exclusion was dropped: ${exclude}`);
}
for (const exclude of METADATA_EXCLUDES) {
assert.ok(tsconfigJson.config.exclude.includes(exclude), `TypeScript metadata exclusion is missing: ${exclude}`);
}
const tscPath = path.resolve(path.dirname(tsEntry), "../bin/tsc");
const baselineTsconfigPath = path.join(fixtureRoot, "tsconfig.app.without-appledouble-excludes.json");
const baselineTsconfigObject = JSON.parse(tsconfigBytes.toString("utf8"));
baselineTsconfigObject.exclude = baselineTsconfigObject.exclude.filter((pattern) => !METADATA_EXCLUDES.includes(pattern));
writeFileSync(baselineTsconfigPath, `${JSON.stringify(baselineTsconfigObject, null, 2)}\n`, "utf8");
const baselineTsconfigJson = ts.readConfigFile(baselineTsconfigPath, ts.sys.readFile);
assert.equal(baselineTsconfigJson.error, undefined, "TypeScript must read the filtered-control app config");
const baselineParsedTsconfig = ts.parseJsonConfigFileContent(
baselineTsconfigJson.config, ts.sys, fixtureRoot, undefined, baselineTsconfigPath,
);
assert.deepEqual(baselineParsedTsconfig.errors, [], "filtered-control TypeScript config must parse without diagnostics");
const baselineTsFiles = baselineParsedTsconfig.fileNames
.map((filePath) => path.relative(fixtureRoot, filePath).split(path.sep).join("/")).sort();
assert.deepEqual(baselineTsFiles, [...SENTINELS].sort(),
"TypeScript 5.8 should continue to skip dot paths even without the explicit filters");
const baselineTsc = runNode([tscPath, "--showConfig", "--project", baselineTsconfigPath], PROJECT_ROOT,
path.join(absoluteEvidence, "tsc-control-show-config.stdout.json"), path.join(absoluteEvidence, "tsc-control-show-config.stderr.log"));
assert.equal(baselineTsc.exitCode, 0, "the filtered-control TypeScript CLI --showConfig must accept the fixture config");
const baselineTscJson = JSON.parse(readFileSync(path.join(absoluteEvidence, "tsc-control-show-config.stdout.json"), "utf8"));
const baselineTscFiles = baselineTscJson.files
.map((filePath) => path.relative(fixtureRoot, path.resolve(fixtureRoot, filePath)).split(path.sep).join("/")).sort();
assert.deepEqual(baselineTscFiles, [...SENTINELS].sort());
assert.deepEqual(baselineTscFiles, baselineTsFiles,
"TypeScript compiler API and CLI must agree on filtered-control src inputs");
const tsc = runNode([tscPath, "--showConfig", "--project", tsconfigPath], PROJECT_ROOT,
path.join(absoluteEvidence, "tsc-show-config.stdout.json"), path.join(absoluteEvidence, "tsc-show-config.stderr.log"));
assert.equal(tsc.exitCode, 0, "the actual TypeScript CLI --showConfig must accept the fixture config");
const tscJson = JSON.parse(readFileSync(path.join(absoluteEvidence, "tsc-show-config.stdout.json"), "utf8"));
const tscFiles = tscJson.files.map((filePath) => path.relative(fixtureRoot, path.resolve(fixtureRoot, filePath)).split(path.sep).join("/")).sort();
assert.deepEqual(tscFiles, [...SENTINELS].sort(), "the TypeScript CLI must resolve the same two ordinary inputs");
const report = {
schemaVersion: 1,
passed: true,
issue: 11,
node: { executable: process.execPath, version: process.version },
invocationCwd: process.cwd(),
tool: { path: fileURLToPath(import.meta.url), sha256: sha256File(fileURLToPath(import.meta.url)) },
project: {
root: PROJECT_ROOT,
gitHead: readGitHead(),
configHashes: configInfo,
},
packageVersions: { vitest: vitestPackage.version, vite: vitePackage.version, typescript: tsPackage.version },
fixtures: {
root: fixtureRoot,
ordinarySentinel: { path: SENTINEL, sha256: sha256File(path.join(fixtureRoot, SENTINEL)) },
ordinaryDotUnderscoreFragment: {
path: SENTINELS[1],
sha256: sha256File(path.join(fixtureRoot, SENTINELS[1])),
reasonIncluded: "the basename contains ._ but does not begin with the AppleDouble component prefix ._",
},
metadataFile: { path: METADATA_FILE, sha256: sha256File(path.join(fixtureRoot, METADATA_FILE)) },
metadataNestedFile: { path: METADATA_NESTED_FILE, sha256: sha256File(path.join(fixtureRoot, METADATA_NESTED_FILE)) },
},
vitest: {
unfilteredControl: {
description: "actual loaded test config with only the two issue-owned excludes removed; all other settings and Vitest built-in defaults are retained",
configSha256: sha256File(baselineConfig),
removedExcludes: METADATA_EXCLUDES,
command: baseline,
jsonReport: { path: baselineJson, sha256: sha256File(baselineJson) },
exitCode: baseline.exitCode,
failures: baselineFailures,
discoveredFiles: baselineFiles,
ordinarySentinelsExecuted: SENTINELS.every((name) => baselineFiles.includes(name)),
metadataFixturesDiscovered: [METADATA_FILE, METADATA_NESTED_FILE].every((name) => baselineFiles.includes(name)),
completedNormally: baseline.exitCode === 0 || baseline.exitCode === 1,
},
candidate: {
command: candidate,
jsonReport: { path: candidateJson, sha256: sha256File(candidateJson) },
exitCode: candidate.exitCode,
discoveredFiles: candidateFiles,
expectedOrdinaryFiles: [...SENTINELS].sort(),
include: testConfig.include,
builtinExcludesPreserved: configDefaults.exclude.every((name) => testConfig.exclude.includes(name)),
metadataExcludes: METADATA_EXCLUDES,
},
},
typescript: {
compilerApi: {
parsedConfigPath: tsconfigPath,
parsedConfigSha256: sha256(tsconfigBytes),
diagnostics: parsedTsconfig.errors.length,
},
baseline: {
configPath: baselineTsconfigPath,
removedExcludes: METADATA_EXCLUDES,
parsedFiles: baselineTsFiles,
metadataFileIncludedBeforeExplicitExcludes: baselineTsFiles.includes(METADATA_FILE),
nestedMetadataIncludedBeforeExplicitExcludes: baselineTsFiles.includes(METADATA_NESTED_FILE),
note: "TypeScript 5.8 already skips dot-prefixed paths during include globbing; the candidate also retains explicit excludes for portable input hygiene.",
cliCommand: baselineTsc,
cliJsonSha256: sha256File(path.join(absoluteEvidence, "tsc-control-show-config.stdout.json")),
},
cliCommand: tsc,
cliJsonSha256: sha256File(path.join(absoluteEvidence, "tsc-show-config.stdout.json")),
parsedFiles: tsFiles,
cliParsedFiles: tscFiles,
include: tsconfigJson.config.include,
types: tsconfigJson.config.compilerOptions.types,
packageExcludesPreserved: expectedPackageExcludes.every((name) => tsconfigJson.config.exclude.includes(name)),
metadataExcludes: METADATA_EXCLUDES,
copiedConfigSha256: sha256(tsconfigBytes),
},
logs: ["vitest-baseline.stdout.log", "vitest-baseline.stderr.log", "vitest-candidate.stdout.log",
"vitest-candidate.stderr.log", "tsc-control-show-config.stderr.log", "tsc-show-config.stderr.log"].map((name) => ({
path: path.join(absoluteEvidence, name),
sha256: sha256File(path.join(absoluteEvidence, name)),
})),
};
writeJson(reportPath, report);
process.stdout.write(`${reportPath}\n`);
}
main().catch((error) => {
const safe = { passed: false, error: `${error.name}: ${error.message}` };
if (evidenceRootForFailure) {
try { writeJson(path.join(evidenceRootForFailure, "failure.json"), safe); } catch { /* preserve original failure */ }
}
process.stderr.write(`${safe.error}\n`);
process.exitCode = 1;
});
+8 -1
View File
@@ -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
View File
@@ -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,
},
};