Update Healer Man server
This commit is contained in:
1 parent
a868401ebc
commit
48a678dcc5
295 files changed
+7644
-467
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 }];
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user