Update Healer Man server

This commit is contained in:
phenom committed 2026-09-25 18:46:09 -04:00
1 parent a868401ebc
commit 48a678dcc5
295 files changed
+7644 -467

No files matched your search

+1
View File
@@ -185,6 +185,7 @@ export function CombatBridge() {
return;
}
const game = useGameStore.getState();
if (game.dungeonIntroActive) return;
const sharedOnline = onlineSessionIsActive();
if ((!game.paused || sharedOnline) && (!game.companionOpen || sharedOnline)) {
advanceOneTick(delta, Date.now(), false);
+3 -3
View File
@@ -35,7 +35,7 @@ export function DungeonRunBridge() {
const advance = () => {
const game = useGameStore.getState();
const shell = useShellStore.getState();
if (shell.phase !== "game" || game.gameMode !== "dungeon" || game.gmTestMode
if (shell.phase !== "game" || game.gameMode !== "dungeon" || game.gmTestMode || game.dungeonIntroActive
|| !["ready", "proxy"].includes(game.assetStatus)) return;
const run = useDungeonRunStore.getState();
if (run.progress?.dungeonId !== game.activeDungeonId) return;
@@ -44,9 +44,9 @@ export function DungeonRunBridge() {
if (activity?.type === "dungeon" && activity.contentId === game.activeDungeonId
&& run.progress.finishedAt === null && run.progress.id !== activity.id) {
useDungeonRunStore.setState({ progress: { ...run.progress, id: activity.id,
startedAt: activity.startedAt, party } });
startedAt: game.dungeonIntroCompletedAt ?? activity.startedAt, party } });
}
run.start(Date.now(), party);
run.start(game.dungeonIntroCompletedAt ?? Date.now(), party);
run.advance(activeDungeonCompletedObjectiveIds(game.activeDungeonId),
Object.values(useCombatStore.getState().mobs).filter((mob) => mob.dead && !mob.despawned).map((mob) => mob.id),
Date.now(), party);
+39
View File
@@ -328,6 +328,45 @@ describe("combat audio output policy", () => {
audio.resetCombatAudio();
});
it("plays separate RuneWaker source cues together on one impact", async () => {
vi.resetModules();
const audio = await import("./combatAudio");
const sources: Array<{ start: ReturnType<typeof vi.fn>; loop: boolean }> = [];
const audioContext = {
currentTime: 0, state: "running", destination: {},
createGain: () => ({ connect: vi.fn(), disconnect: vi.fn(), gain: {
setTargetAtTime: vi.fn(), value: 1,
} }),
createStereoPanner: () => ({ connect: vi.fn(), disconnect: vi.fn() }),
createBufferSource: vi.fn(() => {
const source = { connect: vi.fn(), disconnect: vi.fn(), start: vi.fn(), stop: vi.fn(),
buffer: null, loop: false, onended: null };
sources.push(source);
return source;
}),
createOscillator: vi.fn(),
decodeAudioData: vi.fn(async () => ({ duration: 0.2 })),
resume: vi.fn(async () => undefined),
};
vi.stubGlobal("window", { AudioContext: function () { return audioContext; } });
combatMedia.ready = true;
combatMedia.entry.mockReturnValue({
key: "runewaker:491168", source: "runewaker", spellId: 491168,
sounds: [
{ url: "/assets/game/combat-effects/audio/hit.mp3", phase: "impact" },
{ url: "/assets/game/combat-effects/audio/dot.mp3", phase: "impact" },
],
});
combatMedia.fetch.mockImplementation(async () => new Response(new Uint8Array([1, 2, 3])));
audio.configureCombatAudio(DEFAULT_COMBAT_PRESENTATION_SETTINGS, 0.6);
audio.playCombatAudio({ ...fixtureEvent, source: "runewaker", sourceSpellId: 491168 });
await new Promise((resolve) => setTimeout(resolve, 10));
expect(sources).toHaveLength(2);
expect(sources.every((source) => source.start.mock.calls.length === 1 && !source.loop)).toBe(true);
expect(combatMedia.fetch).toHaveBeenCalledTimes(2);
audio.resetCombatAudio();
});
it("does not start a loop after cancellation and caps a cast loop at its duration", async () => {
vi.resetModules();
const audio = await import("./combatAudio");
+20 -23
View File
@@ -202,18 +202,21 @@ export function selectCombatSoundAssets(
: [];
}
function weightedSound(event: CombatPresentationEvent): CombatEffectSoundAsset | null {
if (!configuredSettings || !authenticCombatAudioEnabled(configuredSettings)) return null;
function soundsForEvent(event: CombatPresentationEvent): readonly CombatEffectSoundAsset[] {
if (!configuredSettings || !authenticCombatAudioEnabled(configuredSettings)) return [];
const sounds = combatEffectManifestEntry(event.sourceSpellId, event.source)?.sounds ?? [];
const playable = selectCombatSoundAssets(sounds, event);
if (!playable.length) return null;
if (!playable.length) return [];
// RuneWaker ImageObject and ROS callbacks are independent client sound
// layers. Choosing one at random drops cast fades or companion impact cues.
if (event.source === "runewaker") return playable;
const total = playable.reduce((sum, sound) => sum + Math.max(1, sound.weight ?? 1), 0);
let cursor = (event.id * 0.61803398875 % 1) * total;
for (const sound of playable) {
cursor -= Math.max(1, sound.weight ?? 1);
if (cursor <= 0) return sound;
if (cursor <= 0) return [sound];
}
return playable[playable.length - 1] ?? null;
return playable.length ? [playable[playable.length - 1]] : [];
}
async function decodeAudio(url: string): Promise<AudioBuffer | null> {
@@ -393,24 +396,18 @@ export function playCombatAudio(event: CombatPresentationEvent): void {
!configuredSettings
|| !combatAudioGroupOutputEnabled(configuredSettings, configuredMasterVolume, event.actorGroup)
) return;
const sound = weightedSound(event);
if (sound) {
const castLoop = event.phase === "cast-start" && sound.loopDuringCast
? beginCastLoop(event)
: undefined;
void playBufferedCombatCue(event, sound, castLoop)
.then((played) => {
if (!played && !castLoop?.cancelled) {
if (castLoop) stopCastLoop(castLoopKey(event));
playGeneratedCombatCue(event);
}
})
.catch(() => {
if (!castLoop?.cancelled) {
if (castLoop) stopCastLoop(castLoopKey(event));
playGeneratedCombatCue(event);
}
});
const sounds = soundsForEvent(event);
if (sounds.length) {
const loopingSound = event.phase === "cast-start"
? sounds.find((sound) => sound.loopDuringCast) : undefined;
const castLoop = loopingSound ? beginCastLoop(event) : undefined;
void Promise.all(sounds.map((sound) => playBufferedCombatCue(
event, sound, sound === loopingSound ? castLoop : undefined,
).catch(() => false))).then((played) => {
if (played.some(Boolean) || castLoop?.cancelled) return;
if (castLoop) stopCastLoop(castLoopKey(event));
playGeneratedCombatCue(event);
});
} else {
playGeneratedCombatCue(event);
}
+6 -2
View File
@@ -154,7 +154,7 @@ describe("combat presentation", () => {
}
});
it("selects only the verified RuneWaker sprite phases for live combat", () => {
it("selects source-linked RuneWaker sprite phases for live combat", () => {
const manifest = JSON.parse(readFileSync(
path.resolve("public/assets/game/combat-effects/manifest.json"),
"utf8",
@@ -168,7 +168,11 @@ describe("combat presentation", () => {
.toEqual(["model/fx/hit_fx/act_shieldstorm_t_freeze_hit_e.ros"]);
expect(originalCombatPhaseAssets(entry(491168), "release").map((asset) => asset.model))
.toEqual(["model/fx/skill/wind/act_electric_ball_f_i.ros"]);
expect(originalCombatPhaseAssets(entry(491168), "impact")).toEqual([]);
expect(originalCombatPhaseAssets(entry(491168), "impact").map((asset) => asset.model))
.toEqual([
"model/fx/skill/wind/act_electric_ball_t_freeze_hit_e.ros",
"model/fx/skill/wind/act_electric_dot_t_down_e.ros",
]);
});
it("keeps every source-backed instant WoW rank on a renderable phase", () => {
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { dungeonBossCards } from "./dungeonBossCards";
import { requireDungeonDefinition } from "./dungeonRegistry";
import { dungeonBossesForDifficulty } from "./dungeonRunStore";
describe("dungeon boss card preview", () => {
it("shows each available boss and the account's owned rarities", () => {
const dungeon = requireDungeonDefinition("wailing-caverns");
const cards = dungeonBossCards(dungeon, dungeon.defaultDifficultyId, {
counts: { "wow:lady anacondra": { normal: 2, rare: 1 } },
lastClaims: {},
});
expect(cards.map((card) => card.name)).toContain("Lady Anacondra");
expect(cards.length).toBe(dungeonBossesForDifficulty(dungeon).length);
expect(cards.find((card) => card.name === "Lady Anacondra")).toMatchObject({
copies: 3,
ownedRarities: ["normal", "rare"],
});
expect(cards.every((card) => Boolean(card.model?.url))).toBe(true);
expect(cards.some((card) => card.copies === 0)).toBe(true);
});
it("uses the rolled boss identity when a seeded run reuses a boss slot", () => {
const dungeon = requireDungeonDefinition("wailing-caverns");
const rolled = { ...dungeon, bosses: [{ ...dungeon.bosses[0], name: "Lord Cobrahn" }] };
const cards = dungeonBossCards(rolled, rolled.defaultDifficultyId, {
counts: { "wow:lord cobrahn": { normal: 1 } },
lastClaims: {},
});
expect(cards).toMatchObject([{ key: "wow:lord cobrahn", name: "Lord Cobrahn", copies: 1 }]);
expect(cards[0]?.model?.url).toContain("lord-cobrahn");
});
});
+43
View File
@@ -0,0 +1,43 @@
import { bossCardByKey, bossCardForSource } from "./bossCardCatalog";
import { BOSS_CARD_RARITIES, type BossCardCollection, type BossCardRarity } from "./bossCards";
import { dungeonBossesForDifficulty } from "./dungeonRunStore";
import type { DungeonDefinition } from "./dungeonTypes";
import type { MobModelStyle } from "./mobPopulation";
export interface DungeonBossCard {
readonly key: string;
readonly name: string;
readonly ownedRarities: readonly BossCardRarity[];
readonly copies: number;
readonly model?: MobModelStyle;
}
/** The preview uses the prepared run definition, so seeded bosses match the actual dungeon. */
export function dungeonBossCards(
dungeon: DungeonDefinition,
difficultyId: string,
collection: BossCardCollection,
): readonly DungeonBossCard[] {
const seen = new Set<string>();
return dungeonBossesForDifficulty(dungeon, difficultyId).flatMap((boss) => {
const category = dungeon.expansion === "runewaker" ? "rom" : "wow";
const normalizedName = boss.name.trim().toLowerCase().replace(/\s+/g, " ");
const fallbackKey = `${category}:${normalizedName}`;
const sourceCard = bossCardForSource(boss.id, dungeon.id, boss.name);
// Seeded variants can reuse an objective id while replacing its boss identity.
const card = sourceCard?.name.trim().toLowerCase().replace(/\s+/g, " ") === normalizedName
? sourceCard : bossCardByKey(fallbackKey);
const spawn = dungeon.staticSpawns.find((entry) => entry.id === boss.id)
?? dungeon.encounterSpawnGroups?.flatMap((group) => group.spawns).find((entry) => entry.id === boss.id);
const activeEntity = spawn ? dungeon.entities[spawn.entityId] : undefined;
const activeModel = activeEntity?.name.trim().toLowerCase().replace(/\s+/g, " ") === normalizedName
? activeEntity.visual.model : undefined;
const key = card?.key ?? fallbackKey;
if (seen.has(key)) return [];
seen.add(key);
const counts = collection.counts[key] ?? {};
const ownedRarities = BOSS_CARD_RARITIES.filter((rarity) => (counts[rarity] ?? 0) > 0);
const copies = BOSS_CARD_RARITIES.reduce((sum, rarity) => sum + (counts[rarity] ?? 0), 0);
return [{ key, name: boss.name, ownedRarities, copies, model: activeModel ?? card?.model }];
});
}
+3
View File
@@ -132,6 +132,9 @@ export function activateDungeonSession(
bossTest: options.bossTest,
gmReviewTarget: options.gmReviewTarget,
bossDifficulty,
showDungeonIntro: !options.preserveCharacter && !options.legacyResume
&& options.runProgress?.startedAt == null && (options.gameMode ?? "dungeon") === "dungeon"
&& !options.gmTestMode && !options.dungeonSimulation,
});
if (options.encounterSnapshots?.length) {
importDungeonEncounterSnapshots(dungeonId, options.encounterSnapshots, { authoritative: true });
+14
View File
@@ -12,11 +12,25 @@ describe("game store", () => {
companionOpen: false,
assetStatus: "checking",
assetMessage: null,
dungeonIntroActive: false,
dungeonIntroCompletedAt: null,
distanceTravelled: 0,
hasMoved: false,
});
});
it("keeps a new dungeon gated until its own intro finishes", () => {
const game = useGameStore.getState();
game.activateDungeon("wailing-caverns", undefined, { showDungeonIntro: true });
const revision = useGameStore.getState().sessionRevision;
expect(useGameStore.getState().dungeonIntroActive).toBe(true);
useGameStore.getState().completeDungeonIntro(revision - 1);
expect(useGameStore.getState().dungeonIntroActive).toBe(true);
useGameStore.getState().completeDungeonIntro(revision);
expect(useGameStore.getState().dungeonIntroActive).toBe(false);
expect(useGameStore.getState().dungeonIntroCompletedAt).toBeGreaterThan(0);
});
it("opens the map without leaving camera control active", () => {
useGameStore.setState({ cameraLookActive: true });
useGameStore.getState().toggleMap();
+11
View File
@@ -36,6 +36,8 @@ export interface GameState {
activeGmReviewTarget: GmReviewTarget | null;
activeSpawn: WorldSpawn;
sessionRevision: number;
dungeonIntroActive: boolean;
dungeonIntroCompletedAt: number | null;
overlay: GameOverlay;
paused: boolean;
mapOpen: boolean;
@@ -67,8 +69,10 @@ export interface GameState {
readonly bossTest?: BossTestSession | null;
readonly gmReviewTarget?: GmReviewTarget | null;
readonly bossDifficulty?: number;
readonly showDungeonIntro?: boolean;
},
) => void;
completeDungeonIntro: (sessionRevision: number) => void;
openOverlay: (overlay: Exclude<GameOverlay, null>) => void;
toggleOverlay: (overlay: Exclude<GameOverlay, null>) => void;
closeOverlay: () => void;
@@ -102,6 +106,8 @@ export const useGameStore = create<GameState>((set) => ({
activeGmReviewTarget: null,
activeSpawn: { footPosition: entrance.footPosition, yaw: entrance.yaw },
sessionRevision: 0,
dungeonIntroActive: false,
dungeonIntroCompletedAt: null,
overlay: null,
paused: false,
mapOpen: false,
@@ -140,6 +146,8 @@ export const useGameStore = create<GameState>((set) => ({
activeGmReviewTarget: options?.gmReviewTarget ?? null,
activeSpawn,
sessionRevision: state.sessionRevision + 1,
dungeonIntroActive: options?.showDungeonIntro ?? false,
dungeonIntroCompletedAt: null,
overlay: null,
paused: false,
mapOpen: false,
@@ -159,6 +167,9 @@ export const useGameStore = create<GameState>((set) => ({
relocationRevision: state.relocationRevision + 1,
};
}),
completeDungeonIntro: (sessionRevision) => set((state) => state.sessionRevision === sessionRevision && state.dungeonIntroActive
? { dungeonIntroActive: false, dungeonIntroCompletedAt: Date.now() }
: state),
openOverlay: (overlay) => set({
overlay,
paused: true,