Release Healer Man 0.1.30

This commit is contained in:
phenom committed 2026-09-11 18:49:33 -04:00
1 parent 544c1bee91
commit 7d7fd567f0
845 files changed
+2040 -87

No files matched your search

+14 -1
View File
@@ -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 });
+3 -1
View File
@@ -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);
}
+46
View File
@@ -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);
});
});
+4 -1
View File
@@ -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"
)
+20
View File
@@ -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
View File
@@ -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);
+20
View File
@@ -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([
+16
View File
@@ -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", () => {
+14 -3
View File
@@ -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,