Release Healer Man 0.1.30
This commit is contained in:
1 parent
544c1bee91
commit
7d7fd567f0
845 files changed
+2040
-87
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const content = vi.hoisted(() => ({
|
||||
usable: false,
|
||||
@@ -35,13 +35,26 @@ import {
|
||||
} from "./combatMediaPack";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("DEV", false);
|
||||
content.usable = false;
|
||||
content.install.mockReset();
|
||||
content.remove.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
describe("original combat media pack", () => {
|
||||
it("uses the locally served originals in development without downloading a release pack", async () => {
|
||||
vi.stubEnv("DEV", true);
|
||||
const response = new Response("original graph");
|
||||
const fetch = vi.fn(async () => response);
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await expect(ensureCombatMediaPack()).resolves.toBe(true);
|
||||
await expect(fetchCombatMediaAsset("/assets/game/combat-effects/manifest.json")).resolves.toBe(response);
|
||||
expect(content.install).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the shared verified content-pack installer", async () => {
|
||||
const progress = vi.fn();
|
||||
await expect(downloadCombatMediaPack(progress)).resolves.toEqual({ fileCount: 12 });
|
||||
|
||||
@@ -80,5 +80,7 @@ export async function removeCombatMediaPack(): Promise<void> {
|
||||
}
|
||||
|
||||
export function combatMediaPackReady(): boolean {
|
||||
return contentPackIsUsable(COMBAT_MEDIA_CONTENT_PACK_ID);
|
||||
// Vite serves the complete source media tree, just as it does dungeon assets.
|
||||
// Shipped builds still require the verified optional content pack.
|
||||
return import.meta.env.DEV || contentPackIsUsable(COMBAT_MEDIA_CONTENT_PACK_ID);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { abilityById } from "./abilityCatalog";
|
||||
import { WOW335_ABILITIES_BY_CLASS } from "./wow335AbilityCatalog";
|
||||
import {
|
||||
DEFAULT_COMBAT_PRESENTATION_SETTINGS,
|
||||
combatEffectManifestEntry,
|
||||
@@ -12,6 +15,8 @@ import {
|
||||
presentationSchoolForAbility,
|
||||
resetCombatPresentationSession,
|
||||
subscribeCombatPresentation,
|
||||
type CombatEffectManifestEntry,
|
||||
type CombatPresentationPhase,
|
||||
type CombatPresentationMessage,
|
||||
} from "./combatPresentation";
|
||||
|
||||
@@ -134,4 +139,45 @@ describe("combat presentation", () => {
|
||||
]);
|
||||
expect(originalCombatPhaseAssets(entry, "release")).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps every source-backed instant WoW rank on a renderable phase", () => {
|
||||
const manifest = JSON.parse(readFileSync(
|
||||
path.resolve("public/assets/game/combat-effects/manifest.json"),
|
||||
"utf8",
|
||||
)) as { readonly entries: readonly CombatEffectManifestEntry[] };
|
||||
const entriesBySpellId = new Map(manifest.entries
|
||||
.filter((entry) => entry.source === "wow335")
|
||||
.map((entry) => [entry.spellId, entry]));
|
||||
const reachablePhases: readonly CombatPresentationPhase[] = [
|
||||
"cast-start",
|
||||
"release",
|
||||
"impact",
|
||||
"aura-start",
|
||||
];
|
||||
const sourceBackedInstantRanks = Object.entries(WOW335_ABILITIES_BY_CLASS)
|
||||
.flatMap(([classId, abilities]) => abilities
|
||||
.flatMap((ability) => (ability.ranks ?? []).map((rank) => ({ ability, classId, rank }))))
|
||||
.filter(({ rank }) => rank.castMode === "instant")
|
||||
.flatMap(({ ability, classId, rank }) => {
|
||||
const entry = entriesBySpellId.get(rank.spellId);
|
||||
if (!entry) return [];
|
||||
const hasOriginal = Object.keys(entry.phases ?? {}).some((phase) => (
|
||||
originalCombatPhaseAssets(entry, phase as CombatPresentationPhase).length > 0
|
||||
));
|
||||
return hasOriginal ? [{
|
||||
abilityId: ability.id,
|
||||
classId,
|
||||
spellId: rank.spellId,
|
||||
entry,
|
||||
}] : [];
|
||||
});
|
||||
const unreachable = sourceBackedInstantRanks.filter(({ entry }) => (
|
||||
!reachablePhases.some((phase) => originalCombatPhaseAssets(entry, phase).length > 0)
|
||||
));
|
||||
|
||||
expect(unreachable.map(({ abilityId, spellId }) => `${abilityId}:${spellId}`)).toEqual([]);
|
||||
expect([...new Set(sourceBackedInstantRanks.map(({ classId }) => classId))].sort())
|
||||
.toEqual(Object.keys(WOW335_ABILITIES_BY_CLASS).sort());
|
||||
expect(sourceBackedInstantRanks.length).toBeGreaterThan(1_000);
|
||||
});
|
||||
});
|
||||
@@ -124,6 +124,8 @@ export interface CombatPresentationEvent {
|
||||
readonly targetPosition?: CombatPresentationPosition;
|
||||
readonly radius?: number;
|
||||
readonly durationMs?: number;
|
||||
/** The cast resolved immediately, so its source cast visual may overlap release. */
|
||||
readonly instantCast?: boolean;
|
||||
}
|
||||
|
||||
export type CombatPresentationEventInput = Omit<CombatPresentationEvent, "id" | "sessionId">;
|
||||
@@ -175,7 +177,7 @@ export interface CombatEffectPhaseAsset {
|
||||
readonly scale?: number;
|
||||
/** Browser-ready graph generated from the original client asset. */
|
||||
readonly effectGraphUrl?: string;
|
||||
readonly effectKind?: "wow-m2-particles" | "wow-m2-static-mesh" | "runewaker-particles";
|
||||
readonly effectKind?: "wow-m2-particles" | "wow-m2-static-mesh" | "wow-m2-model" | "runewaker-particles";
|
||||
readonly originalSourceSha256?: string;
|
||||
}
|
||||
|
||||
@@ -251,6 +253,7 @@ export function originalCombatPhaseAssets(
|
||||
&& asset.effectGraphUrl.startsWith("/assets/game/combat-effects/original/")
|
||||
&& (
|
||||
asset.effectKind === "wow-m2-particles"
|
||||
|| asset.effectKind === "wow-m2-model"
|
||||
|| asset.effectKind === "wow-m2-static-mesh"
|
||||
|| asset.effectKind === "runewaker-particles"
|
||||
)
|
||||
|
||||
@@ -172,6 +172,26 @@ describe("combat store", () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("marks an instant area resolution for its original source cast phase", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "mage", level: 2 });
|
||||
registerTarget("instant-presentation-target", [3, 0, 0], 500, 0);
|
||||
const events: CombatPresentationEvent[] = [];
|
||||
const unsubscribe = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event" && message.event.abilityId === "mage-frost-nova") {
|
||||
events.push(message.event);
|
||||
}
|
||||
});
|
||||
|
||||
expect(useCombatStore.getState().castAbility("mage-frost-nova", [0, 0, 0], 1_000).ok).toBe(true);
|
||||
expect(useCombatStore.getState().activeCast).toBeNull();
|
||||
expect(events.map((event) => event.phase)).toEqual(["release", "impact"]);
|
||||
expect(events.every((event) => event.occurredAt === 1_000)).toBe(true);
|
||||
expect(events.every((event) => event.instantCast)).toBe(true);
|
||||
expect(events.every((event) => event.durationMs === undefined)).toBe(true);
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("identifies the player as the target of a self-cast aura presentation", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "priest", level: 80 });
|
||||
const events: CombatPresentationEvent[] = [];
|
||||
|
||||
+14
-1
@@ -869,6 +869,7 @@ function emitPlayerAbilityPresentation(
|
||||
origin: CombatPosition,
|
||||
targetId: string | null,
|
||||
targetPosition: CombatPosition | null,
|
||||
instantCast = false,
|
||||
): void {
|
||||
const delivery = presentationDeliveryForAbility(ability);
|
||||
const presentationTargetId = targetId ?? (delivery === "aura" ? PLAYER_AURA_ENTITY_ID : null);
|
||||
@@ -888,6 +889,7 @@ function emitPlayerAbilityPresentation(
|
||||
targetPosition: targetPosition ?? origin,
|
||||
...(ability.radius === undefined ? {} : { radius: ability.radius }),
|
||||
...(ability.castTimeMs > 0 ? { durationMs: ability.castTimeMs } : {}),
|
||||
...(instantCast ? { instantCast: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -903,8 +905,9 @@ function emitPlayerAbilityResolutionPresentation(
|
||||
origin: CombatPosition,
|
||||
targetId: string | null,
|
||||
targetPosition: CombatPosition | null,
|
||||
instantCast: boolean,
|
||||
): void {
|
||||
emitPlayerAbilityPresentation(ability, "release", occurredAt, origin, targetId, targetPosition);
|
||||
emitPlayerAbilityPresentation(ability, "release", occurredAt, origin, targetId, targetPosition, instantCast);
|
||||
emitPlayerAbilityPresentation(
|
||||
ability,
|
||||
presentationDeliveryForAbility(ability) === "aura" ? "aura-start" : "impact",
|
||||
@@ -912,6 +915,7 @@ function emitPlayerAbilityResolutionPresentation(
|
||||
origin,
|
||||
targetId,
|
||||
targetPosition,
|
||||
instantCast,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -923,6 +927,7 @@ function emitEnemyAttackPresentation(
|
||||
origin: CombatPosition,
|
||||
targetActorId: AggroActorId | null,
|
||||
targetPosition: CombatPosition | null,
|
||||
instantCast = false,
|
||||
): void {
|
||||
emitCombatPresentation({
|
||||
occurredAt,
|
||||
@@ -940,6 +945,7 @@ function emitEnemyAttackPresentation(
|
||||
targetPosition: targetPosition ?? origin,
|
||||
...(attack.radius === undefined ? {} : { radius: attack.radius }),
|
||||
...((attack.castTimeMs ?? 0) > 0 ? { durationMs: attack.castTimeMs } : {}),
|
||||
...(instantCast ? { instantCast: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5492,6 +5498,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
playerOrigin,
|
||||
targetId,
|
||||
targetPosition,
|
||||
!completingCast,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -6458,6 +6465,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
readonly mob: MobCombatState;
|
||||
readonly attack: EnemyAttackDefinition;
|
||||
readonly attackRevision: number;
|
||||
readonly instantCast: boolean;
|
||||
}> = [];
|
||||
const enemyHealing: Array<{ readonly targetId: string; readonly amount: number }> = [];
|
||||
let playerControl: { readonly mechanic: EnemyControlMechanic; readonly endsAt: number } | null = null;
|
||||
@@ -6657,6 +6665,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
|| isPositionInFacingArc(enemySpatial, candidate, Math.PI);
|
||||
});
|
||||
const presentationTargetId = targets[0] ?? resolution.targetActorId;
|
||||
const instantSourceCast = !resolvedEnemyCast && attack.spellId !== undefined;
|
||||
emitEnemyAttackPresentation(
|
||||
mob,
|
||||
attack,
|
||||
@@ -6665,6 +6674,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
position,
|
||||
presentationTargetId,
|
||||
actorPosition(runtimeState, presentationTargetId),
|
||||
instantSourceCast,
|
||||
);
|
||||
if (effect.kind === "damage") {
|
||||
const baselineDamage = Math.max(0, Math.round(mob.attackDamage * effect.multiplier));
|
||||
@@ -6681,6 +6691,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
mob,
|
||||
attack,
|
||||
attackRevision,
|
||||
instantCast: instantSourceCast,
|
||||
});
|
||||
}
|
||||
} else if (effect.kind === "heal") {
|
||||
@@ -6772,6 +6783,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
mob,
|
||||
attack,
|
||||
attackRevision,
|
||||
instantCast: instantSourceCast,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6949,6 +6961,7 @@ export const useCombatStore = create<CombatState>((set, get) => ({
|
||||
impactOrigin,
|
||||
hit.actorId,
|
||||
impactTarget,
|
||||
hit.instantCast,
|
||||
);
|
||||
}
|
||||
const defendingAuras = aurasForEntity(get().auras, hit.actorId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { clearMobRuntimeRegistry, setMobPosition } from "./mobRuntimeRegistry";
|
||||
import { useCombatStore } from "./combatStore";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
import {
|
||||
normalizeEnemyAttack,
|
||||
type EnemyAttackDefinition,
|
||||
@@ -81,6 +82,25 @@ describe("enemy casting and interrupts", () => {
|
||||
expect(useCombatStore.getState().mobs.caster.activeCast).toBeNull();
|
||||
});
|
||||
|
||||
it("marks an instant source spell release without publishing a synthetic cast event", () => {
|
||||
registerCaster([spell("shadow-bolt", "shadow", 0)]);
|
||||
const events: CombatPresentationEvent[] = [];
|
||||
const unsubscribe = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event" && message.event.sourceActorId === "caster") {
|
||||
events.push(message.event);
|
||||
}
|
||||
});
|
||||
|
||||
expect(useCombatStore.getState().advanceMobCombat(1_000)).toBeGreaterThan(0);
|
||||
expect(events.map((event) => event.phase)).toEqual(["release", "impact"]);
|
||||
expect(events[0]).toMatchObject({
|
||||
sourceSpellId: 686,
|
||||
instantCast: true,
|
||||
});
|
||||
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("interrupts the active cast and locks only that spell school", () => {
|
||||
useCombatStore.getState().initializeCharacter({ classId: "rogue", level: 20 });
|
||||
registerCaster([
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "./partyRuntime";
|
||||
import { usePartyStore } from "./partyStore";
|
||||
import { EMPTY_ITEM_STATS } from "./itemStats";
|
||||
import { subscribeCombatPresentation, type CombatPresentationEvent } from "./combatPresentation";
|
||||
|
||||
function registerEngagedMob(id = "pack:a", maxHealth = 200): void {
|
||||
useCombatStore.getState().registerMob(id, { name: id, maxHealth, xpReward: 0 });
|
||||
@@ -53,6 +54,10 @@ describe("party combat runtime", () => {
|
||||
}
|
||||
const beforeMobHealth = useCombatStore.getState().mobs["pack:a"].health;
|
||||
const fullPlayerHealth = useCombatStore.getState().health;
|
||||
const events: CombatPresentationEvent[] = [];
|
||||
const unsubscribe = subscribeCombatPresentation((message) => {
|
||||
if (message.kind === "event" && message.event.actorGroup === "party") events.push(message.event);
|
||||
});
|
||||
advanceDungeonPartyCombat(1_000);
|
||||
|
||||
const party = usePartyStore.getState();
|
||||
@@ -69,10 +74,21 @@ describe("party combat runtime", () => {
|
||||
const woundedTank = usePartyStore.getState().members.find((member) => member.id === tank.id)!;
|
||||
expect(woundedTank.health).toBeLessThan(woundedTank.maxHealth);
|
||||
expect(useCombatStore.getState().health).toBe(fullPlayerHealth);
|
||||
const instantReleases = events.filter((event) => event.phase === "release" && event.instantCast);
|
||||
expect(instantReleases.length).toBeGreaterThan(0);
|
||||
expect(instantReleases.every((event) => (
|
||||
events.some((impact) => (
|
||||
impact.phase === "impact"
|
||||
&& impact.sourceActorId === event.sourceActorId
|
||||
&& impact.abilityId === event.abilityId
|
||||
&& impact.instantCast
|
||||
))
|
||||
))).toBe(true);
|
||||
|
||||
const actionRevisions = party.members.map((member) => member.nextActionAt);
|
||||
advanceDungeonPartyCombat(1_100);
|
||||
expect(usePartyStore.getState().members.map((member) => member.nextActionAt)).toEqual(actionRevisions);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it("keeps living companions in the fight while the player waits for resurrection", () => {
|
||||
|
||||
@@ -613,6 +613,7 @@ function emitPartyAbility(
|
||||
targetId: string,
|
||||
phase: "cast-start" | "release" | "impact",
|
||||
now: number,
|
||||
instantCast = false,
|
||||
): void {
|
||||
const origin = getPartyRuntimePosition(member.id) ?? useCombatStore.getState().playerPosition;
|
||||
const targetPosition = targetId === PLAYER_AGGRO_ID
|
||||
@@ -633,6 +634,7 @@ function emitPartyAbility(
|
||||
origin,
|
||||
targetPosition,
|
||||
...(ability.radius === undefined ? {} : { radius: ability.radius }),
|
||||
...(instantCast ? { instantCast: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -842,6 +844,7 @@ function executePartyAbility(
|
||||
now: number,
|
||||
members: readonly PartyMember[],
|
||||
depth = 0,
|
||||
castStartAlreadyEmitted = false,
|
||||
): PartyAbilityExecutionResult {
|
||||
const combat = useCombatStore.getState();
|
||||
const origin = getPartyRuntimePosition(member.id) ?? combat.playerPosition;
|
||||
@@ -1025,8 +1028,8 @@ function executePartyAbility(
|
||||
}
|
||||
}
|
||||
}
|
||||
emitPartyAbility(member, ability, targetId, "release", now);
|
||||
emitPartyAbility(member, ability, targetId, "impact", now);
|
||||
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))) {
|
||||
useCombatStore.getState().addHealingThreat({ actorId: member.id, role: member.role, abilityId: ability.id }, effectiveAmount);
|
||||
}
|
||||
@@ -1158,7 +1161,15 @@ export function advanceDungeonPartyCombat(now = Date.now()): void {
|
||||
member.activeCast.origin,
|
||||
);
|
||||
if (valid) {
|
||||
const execution = executePartyAbility(completedMember, castingAbility, castTargetId, now, nextMembers);
|
||||
const execution = executePartyAbility(
|
||||
completedMember,
|
||||
castingAbility,
|
||||
castTargetId,
|
||||
now,
|
||||
nextMembers,
|
||||
0,
|
||||
true,
|
||||
);
|
||||
nextMembers[index] = {
|
||||
...execution.member,
|
||||
status: desiredStatus,
|
||||
|
||||
Reference in new issue
Block a user