217 lines
9.3 KiB
TypeScript
217 lines
9.3 KiB
TypeScript
import {
|
|
classifyEnemyNpcSpellBlockPhase,
|
|
enemyNpcSourceSpellBlockMetadata,
|
|
type EnemyNpcSpellDefenseMetadata,
|
|
type EnemyNpcSpellSourceProfile,
|
|
} from "./enemyBlocking";
|
|
import type { AbilityDefinition, AbilityEffect } from "./abilityCatalog";
|
|
|
|
export type EnemyUtilitySpellCasterKind = "player" | "party" | "enemy-npc" | "rom-native" | "unknown";
|
|
export type EnemyUtilitySpellTargetKind = "ordinary-npc" | "character" | "native-rom" | "summon" | "unknown";
|
|
export type EnemyUtilitySpellHitOutcome = "hit" | "crit" | "miss" | "dodge" | "parry" | "block" | "resist";
|
|
export type EnemyUtilitySpellBlockDisposition = "apply" | "blocked" | "avoided" | "fallback";
|
|
|
|
export interface EnemyUtilitySpellBlockSourceContract {
|
|
readonly spellId: 72 | 1766 | 6552;
|
|
readonly sourceProfile: "wow335";
|
|
readonly abilityId: string;
|
|
/** Raw imported chain ID, accepted only with the same exact source projection. */
|
|
readonly sourceAbilityId?: string;
|
|
readonly classId: "warrior" | "rogue";
|
|
readonly abilityName: "Shield Bash" | "Kick" | "Pummel";
|
|
readonly spellDefense: EnemyNpcSpellDefenseMetadata;
|
|
/** Relevant source fields from the exact selected Spell.dbc rank. */
|
|
readonly dbcEffects: readonly EnemyUtilitySpellBlockDbcEffect[];
|
|
/** Exact projection produced by the playable catalog for this selected rank. */
|
|
readonly projectedEffects: readonly AbilityEffect[];
|
|
}
|
|
|
|
interface EnemyUtilitySpellBlockDbcEffect {
|
|
readonly index: number;
|
|
readonly effectType: number;
|
|
readonly auraType: number;
|
|
readonly mechanic: number;
|
|
readonly targetA: number;
|
|
readonly targetB: number;
|
|
readonly basePoints: number;
|
|
readonly triggerSpellId: number;
|
|
}
|
|
|
|
interface EnemyUtilitySpellBlockRankContract {
|
|
readonly spellId: EnemyUtilitySpellBlockSourceContract["spellId"];
|
|
readonly abilityId: string;
|
|
readonly sourceAbilityId?: string;
|
|
readonly classId: "warrior" | "rogue";
|
|
readonly abilityName: EnemyUtilitySpellBlockSourceContract["abilityName"];
|
|
readonly dbcEffects: readonly EnemyUtilitySpellBlockDbcEffect[];
|
|
readonly projectedEffects: readonly AbilityEffect[];
|
|
}
|
|
|
|
const UTILITY_SPELL_BLOCK_RANKS: readonly EnemyUtilitySpellBlockRankContract[] = Object.freeze([
|
|
Object.freeze({
|
|
spellId: 72,
|
|
abilityId: "wow335-warrior-shield-bash",
|
|
classId: "warrior",
|
|
abilityName: "Shield Bash",
|
|
dbcEffects: Object.freeze([
|
|
Object.freeze({ index: 0, effectType: 68, auraType: 0, mechanic: 26, targetA: 6, targetB: 0, basePoints: 0, triggerSpellId: 0 }),
|
|
Object.freeze({ index: 1, effectType: 64, auraType: 0, mechanic: 0, targetA: 6, targetB: 0, basePoints: 0, triggerSpellId: 29703 }),
|
|
]),
|
|
projectedEffects: Object.freeze([
|
|
Object.freeze({ kind: "interrupt", lockoutMs: 6_000 }),
|
|
Object.freeze({ kind: "trigger-spell", spellId: 29_703 }),
|
|
]),
|
|
}),
|
|
Object.freeze({
|
|
spellId: 1766,
|
|
abilityId: "rogue-kick",
|
|
sourceAbilityId: "wow335-rogue-kick",
|
|
classId: "rogue",
|
|
abilityName: "Kick",
|
|
dbcEffects: Object.freeze([
|
|
Object.freeze({ index: 0, effectType: 68, auraType: 0, mechanic: 26, targetA: 6, targetB: 0, basePoints: 1, triggerSpellId: 0 }),
|
|
Object.freeze({ index: 1, effectType: 0, auraType: 0, mechanic: 0, targetA: 0, targetB: 0, basePoints: 0, triggerSpellId: 0 }),
|
|
]),
|
|
projectedEffects: Object.freeze([
|
|
Object.freeze({ kind: "interrupt", lockoutMs: 5_000 }),
|
|
]),
|
|
}),
|
|
Object.freeze({
|
|
spellId: 6552,
|
|
abilityId: "wow335-warrior-pummel",
|
|
classId: "warrior",
|
|
abilityName: "Pummel",
|
|
dbcEffects: Object.freeze([
|
|
Object.freeze({ index: 0, effectType: 68, auraType: 0, mechanic: 26, targetA: 6, targetB: 0, basePoints: 1, triggerSpellId: 0 }),
|
|
Object.freeze({ index: 1, effectType: 0, auraType: 0, mechanic: 0, targetA: 0, targetB: 0, basePoints: 0, triggerSpellId: 0 }),
|
|
]),
|
|
projectedEffects: Object.freeze([
|
|
Object.freeze({ kind: "interrupt", lockoutMs: 4_000 }),
|
|
]),
|
|
}),
|
|
]);
|
|
|
|
/**
|
|
* Returns an audited utility full-block contract for an exact WoW 3.3.5a
|
|
* selected rank. Other ranks and source profiles remain on their current path.
|
|
*/
|
|
export function enemyUtilitySpellBlockSourceContract(
|
|
spellId: number | undefined,
|
|
sourceProfile: EnemyNpcSpellSourceProfile | undefined,
|
|
): EnemyUtilitySpellBlockSourceContract | undefined {
|
|
if (sourceProfile !== "wow335") return undefined;
|
|
const rank = UTILITY_SPELL_BLOCK_RANKS.find((candidate) => candidate.spellId === spellId);
|
|
if (!rank) return undefined;
|
|
const spellDefense = enemyNpcSourceSpellBlockMetadata(rank.spellId, "wow335");
|
|
if (!spellDefense) return undefined;
|
|
return Object.freeze({
|
|
spellId: rank.spellId,
|
|
sourceProfile: "wow335",
|
|
abilityId: rank.abilityId,
|
|
...(rank.sourceAbilityId ? { sourceAbilityId: rank.sourceAbilityId } : {}),
|
|
classId: rank.classId,
|
|
abilityName: rank.abilityName,
|
|
spellDefense,
|
|
dbcEffects: rank.dbcEffects,
|
|
projectedEffects: rank.projectedEffects,
|
|
});
|
|
}
|
|
|
|
function matchesUtilityBlockMetadata(
|
|
metadata: EnemyNpcSpellDefenseMetadata | undefined,
|
|
contract: EnemyUtilitySpellBlockSourceContract,
|
|
reconstructSourceProof = false,
|
|
): boolean {
|
|
if (!metadata) return false;
|
|
const expected = contract.spellDefense;
|
|
if (metadata.damageClass !== expected.damageClass
|
|
|| metadata.schoolMask !== expected.schoolMask
|
|
|| metadata.attributes0 !== expected.attributes0
|
|
|| metadata.attributes3 !== expected.attributes3
|
|
|| metadata.directDamageFromEffects !== expected.directDamageFromEffects
|
|
|| metadata.sourceProfile !== expected.sourceProfile) return false;
|
|
|
|
const knownCustom = expected.customAttributeEvidence;
|
|
if (knownCustom) {
|
|
if (metadata.customAttributes !== undefined
|
|
&& ((metadata.customAttributes & knownCustom.knownMask) >>> 0) !== knownCustom.value) return false;
|
|
const evidence = metadata.customAttributeEvidence;
|
|
if (evidence && ((evidence.knownMask & knownCustom.knownMask) !== knownCustom.knownMask
|
|
|| ((evidence.value & knownCustom.knownMask) >>> 0) !== knownCustom.value)) return false;
|
|
}
|
|
const withSourceProof = reconstructSourceProof
|
|
&& metadata.customAttributes === undefined
|
|
&& metadata.customAttributeEvidence === undefined && knownCustom
|
|
? { ...metadata, customAttributeEvidence: knownCustom }
|
|
: metadata;
|
|
return classifyEnemyNpcSpellBlockPhase(withSourceProof) === "hit-table";
|
|
}
|
|
|
|
function stableSignature(value: unknown): string {
|
|
if (Array.isArray(value)) return `[${value.map(stableSignature).join(",")}]`;
|
|
if (value && typeof value === "object") {
|
|
const entries = Object.entries(value as Record<string, unknown>)
|
|
.sort(([left], [right]) => left.localeCompare(right));
|
|
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${stableSignature(entry)}`).join(",")}}`;
|
|
}
|
|
return JSON.stringify(value) ?? "undefined";
|
|
}
|
|
|
|
/**
|
|
* Binds the spell-block contract to the actual selected playable catalog row.
|
|
* Matching a spell ID alone cannot authorize another class, source, wrapper,
|
|
* or modified effect list to borrow this source proof.
|
|
*/
|
|
export function enemyUtilitySpellBlockAbilityContract(
|
|
ability: AbilityDefinition | undefined,
|
|
): EnemyUtilitySpellBlockSourceContract | undefined {
|
|
if (!ability) return undefined;
|
|
const contract = enemyUtilitySpellBlockSourceContract(
|
|
ability.dbcSpellId,
|
|
ability.wowSpellDefense?.sourceProfile,
|
|
);
|
|
if (!contract
|
|
|| (ability.id !== contract.abilityId && ability.id !== contract.sourceAbilityId)
|
|
|| ability.classId !== contract.classId
|
|
|| ability.name !== contract.abilityName
|
|
|| ability.source !== "wow335-progression"
|
|
|| ability.dbcSpellId !== contract.spellId
|
|
|| !matchesUtilityBlockMetadata(ability.wowSpellDefense, contract, true)
|
|
|| stableSignature(ability.effects) !== stableSignature(contract.projectedEffects)) return undefined;
|
|
return contract;
|
|
}
|
|
|
|
/**
|
|
* Resolves whether one already-computed hit-table result suppresses the
|
|
* utility effects for its exact cast recipient. It does not roll a hit or
|
|
* block, and it does not widen the ordinary NPC defender rules.
|
|
*/
|
|
export function enemyUtilitySpellBlockDisposition(input: {
|
|
readonly spellId: number | undefined;
|
|
readonly sourceProfile: EnemyNpcSpellSourceProfile | undefined;
|
|
readonly spellDefense: EnemyNpcSpellDefenseMetadata | undefined;
|
|
readonly casterKind: EnemyUtilitySpellCasterKind;
|
|
readonly targetKind: EnemyUtilitySpellTargetKind;
|
|
readonly outcome: EnemyUtilitySpellHitOutcome | undefined;
|
|
}): EnemyUtilitySpellBlockDisposition {
|
|
const contract = enemyUtilitySpellBlockSourceContract(input.spellId, input.sourceProfile);
|
|
if (!contract
|
|
|| input.casterKind !== "player" && input.casterKind !== "party"
|
|
|| input.targetKind !== "ordinary-npc"
|
|
|| !matchesUtilityBlockMetadata(input.spellDefense, contract)) return "fallback";
|
|
|
|
if (input.outcome === "block") return "blocked";
|
|
if (input.outcome === "miss" || input.outcome === "dodge"
|
|
|| input.outcome === "parry" || input.outcome === "resist") return "avoided";
|
|
if (input.outcome === "hit" || input.outcome === "crit") return "apply";
|
|
return "fallback";
|
|
}
|
|
|
|
/** Leaves unsupported calls on their existing path; a resolved miss or full block has no utility effects. */
|
|
export function enemyUtilityEffectsAfterBlock<T>(
|
|
effects: readonly T[],
|
|
disposition: EnemyUtilitySpellBlockDisposition,
|
|
): readonly T[] {
|
|
return disposition === "blocked" || disposition === "avoided" ? [] : effects;
|
|
}
|